Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b1be04e
Fix historical usage limit backfill
niemyjski Jul 28, 2026
d0c8272
Derive usage backfill from known limits
niemyjski Jul 29, 2026
52a50b8
Update plan change usage assertions
niemyjski Jul 29, 2026
a7c3ed0
Consolidate usage bucket creation
niemyjski Jul 29, 2026
8c869fa
Bound usage history by organization creation
niemyjski Jul 30, 2026
9b7c2de
Update new organization usage expectations
niemyjski Jul 30, 2026
4df6dcb
Update plan change usage count expectation
niemyjski Jul 30, 2026
a249dda
Harden usage history reconstruction
niemyjski Jul 30, 2026
66079e6
Keep usage history fix scoped
niemyjski Jul 30, 2026
456383c
Anchor bonuses to historical month
niemyjski Jul 30, 2026
431e4e3
Handle usage bonus month boundaries
niemyjski Jul 30, 2026
77f1dd5
Disambiguate equal historical limits
niemyjski Jul 30, 2026
e2d9181
Repair zero-valued usage anchors
niemyjski Jul 30, 2026
88b9f8d
Anchor only confirmed plan history
niemyjski Jul 30, 2026
541a387
Compare plan identifiers consistently
niemyjski Jul 30, 2026
6e9af55
Preserve usage fallback base limits
niemyjski Jul 30, 2026
8c48fa5
Preserve outgoing usage anchors with new bonuses
niemyjski Jul 30, 2026
e9fe951
Stop expired bonus limits from carrying forward
niemyjski Jul 30, 2026
afd3733
Preserve equal-valued outgoing usage anchors
niemyjski Jul 30, 2026
e2bc280
Preserve billing change dates during maintenance
niemyjski Jul 30, 2026
27ae6db
Treat missing billing history as unknown
niemyjski Jul 30, 2026
8d4f6a2
Bound project usage to creation dates
niemyjski Jul 30, 2026
0dd16f3
Simplify usage history backfill
niemyjski Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/Exceptionless.Core/Billing/BillingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Billing;
using Exceptionless.Core.Repositories;
using Exceptionless.DateTimeExtensions;

namespace Exceptionless.Core.Billing;

Expand Down Expand Up @@ -96,16 +97,23 @@ public async Task<ChangePlanResult> 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;
Expand All @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/Exceptionless.Core/Extensions/UsageExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<UsageInfo> 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;
Comment thread
niemyjski marked this conversation as resolved.
}

usage = new UsageInfo
{
Date = startOfMonth,
Limit = limit
};
usages.Add(usage);

return usage;
}

public static void SetUsage(this ICollection<UsageInfo> usages, DateTime dateUtc, int total, int blocked, int tooBig, int limit, TimeSpan? maxUsageAge, TimeProvider timeProvider)
{
var usageInfo = usages.FirstOrDefault(o => o.Date == dateUtc);
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ private async Task AfterResultMapAsync<TDestination>(ICollection<TDestination> 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);
Expand Down
47 changes: 30 additions & 17 deletions src/Exceptionless.Web/Models/Organization/ViewOrganization.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.DateTimeExtensions;
using Foundatio.Repositories.Models;
Expand Down Expand Up @@ -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;
Comment thread
niemyjski marked this conversation as resolved.
}

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();
}
}

Expand All @@ -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)
Expand Down
20 changes: 15 additions & 5 deletions src/Exceptionless.Web/Models/Project/ViewProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand Down
10 changes: 5 additions & 5 deletions tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewOrganization>(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()
{
Expand Down
Loading
Loading