diff --git a/build/EmailTemplatePreviews.cs b/build/EmailTemplatePreviews.cs
index 1e88ed5481..52957bffa7 100644
--- a/build/EmailTemplatePreviews.cs
+++ b/build/EmailTemplatePreviews.cs
@@ -201,6 +201,11 @@ EmailLink[] GetActionLinks(string stackId) =>
"[Engineering] Payment failed! Update billing information to avoid service interruption!",
"Engineering",
$"{appUrl}/organization/organization-1/billing")),
+ new("organization-trial-ending", "Trial ending", new OrganizationTrialEndingEmail(
+ "[Engineering] Your free trial ends soon",
+ "Engineering",
+ "Friday, October 30, 2026",
+ $"{appUrl}/organization/organization-1/upgrade")),
new("project-daily-summary", "Daily summary", new ProjectDailySummaryEmail(
"[Storefront API] Summary for August 24, 2026",
"Storefront API",
diff --git a/docs/privacy.md b/docs/privacy.md
index fdd73cc09a..945cb56f4a 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -23,7 +23,8 @@ We collect the e-mail addresses of those who communicate with us via e-mail or t
## Data storage
* Exceptionless uses third party vendors and hosting partners to provide the necessary hardware, software, networking, storage, and related technology required to run Exceptionless. Although Exceptionless owns the code, databases, and all rights to the Exceptionless application, you retain all rights to your data.
-* You can delete your personal data at any time by visiting the my account page and clicking the delete account button.
+* You can delete your account data at any time by visiting the my account page and clicking the delete account button. Account deletion does not automatically remove the separate trial eligibility record described below.
+* To limit repeated free trials, we keep a separate trial eligibility record containing only a keyed, one-way identifier of your email address and the trial claim date. This record does not contain your email address, user ID, or organization ID. It expires after 365 days, including when you delete your account, and is removed through routine cleanup. After it expires, your mailbox can be eligible for another trial.
## Disclosure
diff --git a/k8s/exceptionless/templates/jobs.yaml b/k8s/exceptionless/templates/jobs.yaml
index 3a71d1a8ae..af582bf1a8 100644
--- a/k8s/exceptionless/templates/jobs.yaml
+++ b/k8s/exceptionless/templates/jobs.yaml
@@ -789,6 +789,63 @@ spec:
{{- include "exceptionless.otel-env" . | indent 16 }}
restartPolicy: OnFailure
+---
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ template "exceptionless.fullname" . }}-jobs-trial-expiration
+ labels:
+ app: {{ template "exceptionless.name" . }}
+ component: {{ template "exceptionless.fullname" . }}-jobs-trial-expiration
+ tier: {{ template "exceptionless.fullname" . }}-job
+ chart: {{ template "exceptionless.chart" . }}
+ release: {{ .Release.Name }}
+ heritage: {{ .Release.Service }}
+spec:
+ schedule: "15 * * * *" # hourly
+ concurrencyPolicy: Forbid
+ jobTemplate:
+ spec:
+ template:
+ spec:
+ containers:
+ - name: {{ template "exceptionless.name" . }}-jobs-trial-expiration
+ image: "{{ .Values.jobs.image.repository }}:{{ .Values.version }}"
+ imagePullPolicy: {{ .Values.jobs.image.pullPolicy }}
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources:
+ requests:
+ memory: 75Mi
+ cpu: 50m
+ limits:
+ memory: 250Mi
+ cpu: 400m
+ args: [TrialExpiration]
+ envFrom:
+ - configMapRef:
+ name: {{ template "exceptionless.fullname" . }}-config
+ env:
+ - name: HOST_IP
+ valueFrom:
+ fieldRef:
+ fieldPath: status.hostIP
+ - name: K8S_POD_IP
+ valueFrom:
+ fieldRef:
+ apiVersion: v1
+ fieldPath: status.podIP
+ - name: K8S_POD_UID
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.uid
+{{- include "exceptionless.otel-env" . | indent 16 }}
+ restartPolicy: OnFailure
+
---
apiVersion: apps/v1
kind: Deployment
diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs
index 3ebc2b3781..4b512bd91e 100644
--- a/src/Exceptionless.Core/Billing/BillingManager.cs
+++ b/src/Exceptionless.Core/Billing/BillingManager.cs
@@ -1,34 +1,84 @@
-using Exceptionless.Core.Extensions;
+using Exceptionless.Core.Configuration;
+using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Billing;
using Exceptionless.Core.Repositories;
+using Exceptionless.Core.Services;
+using Exceptionless.DateTimeExtensions;
+using Foundatio.Caching;
using Foundatio.Lock;
+using Foundatio.Repositories;
+using Foundatio.Repositories.Exceptions;
+using Foundatio.Utility;
namespace Exceptionless.Core.Billing;
public class BillingManager
{
private static readonly TimeSpan BillingLockDuration = TimeSpan.FromMinutes(5);
+ private readonly ICacheClient _cacheClient;
private readonly ILockProvider _lockProvider;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProjectRepository _projectRepository;
+ private readonly ITrialClaimRepository _trialClaimRepository;
private readonly IUserRepository _userRepository;
+ private readonly SystemSettingsService _systemSettingsService;
private readonly BillingPlans _plans;
+ private readonly BillingOptions _billingOptions;
private readonly TimeProvider _timeProvider;
- public BillingManager(ILockProvider lockProvider, IOrganizationRepository organizationRepository, IProjectRepository projectRepository, IUserRepository userRepository, BillingPlans plans, TimeProvider timeProvider)
+ public BillingManager(
+ ICacheClient cacheClient,
+ ILockProvider lockProvider,
+ IOrganizationRepository organizationRepository,
+ IProjectRepository projectRepository,
+ ITrialClaimRepository trialClaimRepository,
+ IUserRepository userRepository,
+ SystemSettingsService systemSettingsService,
+ BillingPlans plans,
+ AppOptions appOptions,
+ TimeProvider timeProvider)
{
+ _cacheClient = cacheClient;
_lockProvider = lockProvider;
_organizationRepository = organizationRepository;
_projectRepository = projectRepository;
+ _trialClaimRepository = trialClaimRepository;
_userRepository = userRepository;
+ _systemSettingsService = systemSettingsService;
_plans = plans;
+ _billingOptions = appOptions.BillingOptions;
_timeProvider = timeProvider;
}
public Task AcquireOrganizationLockAsync(string organizationId)
=> _lockProvider.AcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.FromSeconds(30));
+ ///
+ /// Serializes creating Free or trial organizations and downgrades to Free for one owner, so concurrent requests
+ /// can't both pass the one-free-organization rule. It lasts as long as the billing lock because a downgrade holds
+ /// it across the same Stripe calls.
+ ///
+ public Task AcquireOwnerFreeOrganizationLockAsync(string userId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userId);
+
+ return _lockProvider.AcquireAsync(GetOwnerFreeOrganizationLockKey(userId), BillingLockDuration, TimeSpan.FromSeconds(30));
+ }
+
+ ///
+ /// Serializes trial starts for one mailbox, so two aliases of it can't both claim a trial at once.
+ ///
+ public Task AcquireTrialEmailLockAsync(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (_billingOptions.TrialClaimSecret is null)
+ return Task.FromResult(EmptyLock.Empty);
+
+ return _lockProvider.AcquireAsync(GetTrialEmailLockKey(user.EmailAddress), BillingLockDuration, TimeSpan.FromSeconds(30));
+ }
+
public Task TryAcquireOrganizationLockAsync(string organizationId)
=> _lockProvider.TryAcquireAsync(GetOrganizationLockKey(organizationId), BillingLockDuration, TimeSpan.Zero);
@@ -37,8 +87,122 @@ public async Task CanAddOrganizationAsync(User? user)
if (user is null)
return false;
- var organizations = (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Where(o => o.PlanId == _plans.FreePlan.Id);
- return !organizations.Any();
+ var organizations = await GetFreeOrTrialOrganizationsCountedForAsync(user);
+ return organizations.Count is 0;
+ }
+
+ ///
+ /// A no-card trial is for a user who belongs to no organization and has no existing organizations they created,
+ /// whose mailbox hasn't claimed a trial in the last days (under
+ /// another alias or a deleted account), and whose email isn't on a blocked disposable domain. Call it while holding
+ /// .
+ ///
+ public async Task CanStartTrialAsync(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (_billingOptions.TrialClaimSecret is null || _billingOptions.TrialDays is <= 0 || user.OrganizationIds.Count is > 0)
+ return false;
+
+ if (user.TrialStartedUtc is { } trialStartedUtc && trialStartedUtc.IsAfter(GetTrialClaimCutoffUtc()))
+ return false;
+
+ var blockedEmailDomains = await _systemSettingsService.GetTrialBlockedEmailDomainsAsync();
+ if (TrialEmailAddress.IsBlockedDomain(user.EmailAddress, blockedEmailDomains))
+ return false;
+
+ var created = await _organizationRepository.CountAsync(q => q.FieldEquals(o => o.CreatedByUserId, user.Id));
+ if (created.Total is > 0)
+ return false;
+
+ var claim = await _trialClaimRepository.GetByIdAsync(TrialEmailAddress.GetClaimId(user.EmailAddress, _billingOptions.TrialClaimSecret));
+ return !IsActiveClaim(claim);
+ }
+
+ public void StartTrial(Organization organization, User user)
+ {
+ ArgumentNullException.ThrowIfNull(organization);
+ ArgumentNullException.ThrowIfNull(user);
+
+ ApplyTrialPlan(organization, user);
+ user.TrialStartedUtc = _timeProvider.GetUtcNow().UtcDateTime;
+ }
+
+ ///
+ /// Tries to record the user's mailbox trial claim after confirming eligibility and before granting the trial, while
+ /// still holding . The claim remains if subsequent organization creation
+ /// fails because the outcome of those writes may be uncertain.
+ ///
+ /// True only when this call created or renewed the mailbox claim; false when an active claim already exists.
+ public async Task TryClaimTrialAsync(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ string secret = _billingOptions.TrialClaimSecret ?? throw new InvalidOperationException("Billing:TrialClaimSecret is required to claim a trial");
+ string claimId = TrialEmailAddress.GetClaimId(user.EmailAddress, secret);
+ try
+ {
+ // Add is create-only: the normalized mailbox ID enforces uniqueness even without the lock.
+ await _trialClaimRepository.AddAsync(new TrialClaim { Id = claimId, CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime });
+ return true;
+ }
+ catch (DuplicateDocumentException)
+ {
+ // Renew expired claims with a version check, so storage enforces uniqueness even if the mailbox lock is lost.
+ var claim = await _trialClaimRepository.GetByIdAsync(claimId);
+ if (IsActiveClaim(claim))
+ return false;
+
+ try
+ {
+ if (claim is null)
+ {
+ // Cleanup removed the expired claim after the create failed; create-only admission still applies.
+ await _trialClaimRepository.AddAsync(new TrialClaim { Id = claimId, CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime });
+ }
+ else
+ {
+ claim.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime;
+ await _trialClaimRepository.SaveAsync(claim);
+ }
+
+ return true;
+ }
+ catch (DocumentException ex) when (ex is DuplicateDocumentException or VersionConflictDocumentException)
+ {
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Removes trial claims older than , so those mailboxes can trial again.
+ ///
+ public Task RemoveExpiredTrialClaimsAsync()
+ => _trialClaimRepository.RemoveCreatedBeforeAsync(GetTrialClaimCutoffUtc());
+
+ private bool IsActiveClaim(TrialClaim? claim) => claim is not null && claim.CreatedUtc.IsAfter(GetTrialClaimCutoffUtc());
+
+ private DateTime GetTrialClaimCutoffUtc() => _timeProvider.GetUtcNow().UtcDateTime.AddDays(-_billingOptions.TrialClaimRetentionDays);
+
+ ///
+ /// Applies the trial plan and starts a full-length trial. Also used by admins to extend a trial.
+ ///
+ public void ApplyTrialPlan(Organization organization, User? user = null)
+ {
+ ArgumentNullException.ThrowIfNull(organization);
+
+ int trialDays = _billingOptions.TrialDays is > 0 ? _billingOptions.TrialDays : 14;
+ ApplyBillingPlan(organization, _plans.TrialPlan, user);
+ organization.TrialEndsUtc = _timeProvider.GetUtcNow().UtcDateTime.AddDays(trialDays);
+ organization.TrialReminderSentUtc = null;
+ }
+
+ public void EndTrial(Organization organization)
+ {
+ ArgumentNullException.ThrowIfNull(organization);
+
+ ApplyBillingPlan(organization, _plans.FreePlan);
}
public async Task CanAddUserAsync(Organization organization)
@@ -46,8 +210,11 @@ public async Task CanAddUserAsync(Organization organization)
if (String.IsNullOrWhiteSpace(organization?.Id))
return false;
- long numberOfUsers = (await _userRepository.GetByOrganizationIdAsync(organization.Id)).Total + organization.Invites.Count;
- return organization.MaxUsers <= -1 || numberOfUsers < organization.MaxUsers;
+ if (organization.MaxUsers is BillingPlan.Unlimited)
+ return true;
+
+ var users = await _userRepository.GetByOrganizationIdAsync(organization.Id);
+ return users.Total + organization.Invites.Count < organization.MaxUsers;
}
public async Task CanAddProjectAsync(Project project)
@@ -59,8 +226,11 @@ public async Task CanAddProjectAsync(Project project)
if (organization is null)
return false;
- long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(project.OrganizationId);
- return organization.MaxProjects == -1 || projectCount < organization.MaxProjects;
+ if (organization.MaxProjects is BillingPlan.Unlimited)
+ return true;
+
+ long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(organization.Id);
+ return projectCount < organization.MaxProjects;
}
public async Task HasPremiumFeaturesAsync(string organizationId)
@@ -77,23 +247,80 @@ public async Task CanDownGradeAsync(Organization organization,
if (String.IsNullOrWhiteSpace(organization?.Id))
return ChangePlanResult.FailWithMessage("Invalid Organization");
- long currentNumberOfUsers = (await _userRepository.GetByOrganizationIdAsync(organization.Id)).Total + organization.Invites.Count;
- int maxUsers = plan.MaxUsers != -1 ? plan.MaxUsers : Int32.MaxValue;
- if (currentNumberOfUsers > maxUsers)
- return ChangePlanResult.FailWithMessage($"Please remove {currentNumberOfUsers - maxUsers} user{((currentNumberOfUsers - maxUsers) > 0 ? "s" : String.Empty)} and try again.");
+ // Ensure the organization's owner can't own more than one free or trial organization, no matter which member downgrades it.
+ if (_plans.IsFreePlan(plan.Id) && user is not null)
+ {
+ bool hasOtherFreeOrTrialOrganization = await OwnerHasOtherFreeOrTrialOrganizationAsync(organization, user);
+ if (hasOtherFreeOrTrialOrganization)
+ return ChangePlanResult.FailWithMessage("You already have one free account. You are not allowed to create more than one free account.");
+ }
- int maxProjects = plan.MaxProjects != -1 ? plan.MaxProjects : Int32.MaxValue;
- long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(organization.Id);
- if (projectCount > maxProjects)
- return ChangePlanResult.FailWithMessage($"Please remove {projectCount - maxProjects} project{((projectCount - maxProjects) > 0 ? "s" : String.Empty)} and try again.");
-
- // Ensure the user can't be apart of more than one free plan.
- if (String.Equals(plan.Id, _plans.FreePlan.Id) && user is not null && (await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray())).Any(o => String.Equals(o.PlanId, _plans.FreePlan.Id)))
- return ChangePlanResult.FailWithMessage("You already have one free account. You are not allowed to create more than one free account.");
+ string? limitError = await GetPlanLimitErrorAsync(organization, plan.MaxUsers, plan.MaxProjects);
+ if (limitError is not null)
+ return ChangePlanResult.FailWithMessage(limitError);
return new ChangePlanResult { Success = true };
}
+ private async Task GetPlanLimitErrorAsync(Organization organization, int maxUsers, int maxProjects)
+ {
+ if (maxUsers is >= 0)
+ {
+ var users = await _userRepository.GetByOrganizationIdAsync(organization.Id);
+ long excessUsers = users.Total + organization.Invites.Count - maxUsers;
+ if (excessUsers is > 0)
+ return $"Please remove {excessUsers} {(excessUsers is 1 ? "user or pending invitation" : "users or pending invitations")} and try again.";
+ }
+
+ if (maxProjects is >= 0)
+ {
+ long projectCount = await _projectRepository.GetCountByOrganizationIdAsync(organization.Id);
+ long excessProjects = projectCount - maxProjects;
+ if (excessProjects is > 0)
+ return $"Please remove {excessProjects} {(excessProjects is 1 ? "project" : "projects")} and try again.";
+ }
+
+ return null;
+ }
+
+ public async Task UpdatePlanLimitSuspensionAsync(Organization organization)
+ {
+ if (organization.IsSuspended && organization.SuspensionCode is not SuspensionCode.PlanLimit)
+ return false;
+
+ string? error = await GetPlanLimitErrorAsync(organization, organization.MaxUsers, organization.MaxProjects);
+ if (error is null)
+ {
+ if (organization.SuspensionCode is not SuspensionCode.PlanLimit)
+ return false;
+
+ organization.RemoveSuspension();
+ return true;
+ }
+
+ if (organization.IsSuspended && String.Equals(organization.SuspensionNotes, error, StringComparison.Ordinal))
+ return false;
+
+ organization.IsSuspended = true;
+ organization.SuspensionCode = SuspensionCode.PlanLimit;
+ organization.SuspensionDate = _timeProvider.GetUtcNow().UtcDateTime;
+ organization.SuspendedByUserId = StripeConstants.SystemUserId;
+ organization.SuspensionNotes = error;
+ return true;
+ }
+
+ public async Task RecheckPlanLimitSuspensionAsync(string organizationId)
+ {
+ await using var billingLock = await AcquireOrganizationLockAsync(organizationId);
+ var organization = await _organizationRepository.GetByIdAsync(organizationId);
+ if (organization is null || organization.SuspensionCode is not SuspensionCode.PlanLimit)
+ return;
+
+ bool changed = await UpdatePlanLimitSuspensionAsync(organization);
+ if (changed)
+ await _organizationRepository.SaveAsync(organization, command => command.Cache().Originals());
+ }
+
public BillingPlan? GetBillingPlan(string planId)
{
return _plans.Plans.FirstOrDefault(p => String.Equals(p.Id, planId, StringComparison.OrdinalIgnoreCase));
@@ -136,6 +363,7 @@ public void ApplyBonus(Organization organization, int bonusEvents, DateTime? exp
internal void SetStripeSubscriptionId(Organization organization, string? subscriptionId)
{
ArgumentNullException.ThrowIfNull(organization);
+
if (String.Equals(organization.StripeSubscriptionId, subscriptionId, StringComparison.Ordinal))
return;
@@ -143,9 +371,74 @@ internal void SetStripeSubscriptionId(Organization organization, string? subscri
organization.StripeSubscriptionEventDate = null;
}
+ private async Task OwnerHasOtherFreeOrTrialOrganizationAsync(Organization organization, User actingUser)
+ {
+ // Organizations created before creators were recorded have no owner, so the member downgrading them is held to the rule.
+ var owner = actingUser;
+ if (organization.CreatedByUserId is not null && !String.Equals(organization.CreatedByUserId, actingUser.Id, StringComparison.Ordinal))
+ owner = await _userRepository.GetByIdAsync(organization.CreatedByUserId, o => o.Cache(false));
+
+ IReadOnlyCollection counted;
+ if (owner is not null)
+ counted = await GetFreeOrTrialOrganizationsCountedForAsync(owner);
+ else
+ counted = await GetCreatedFreeOrTrialOrganizationsAsync(organization.CreatedByUserId!);
+ return counted.Any(o => !String.Equals(o.Id, organization.Id, StringComparison.Ordinal));
+ }
+
+ ///
+ /// The free and trial organizations that count toward a user's one-free-organization limit: the ones they created,
+ /// plus, for organizations created before creators were recorded, the ones they're a member of.
+ ///
+ private async Task> GetFreeOrTrialOrganizationsCountedForAsync(User user)
+ {
+ var created = await GetCreatedFreeOrTrialOrganizationsAsync(user.Id);
+ if (user.OrganizationIds.Count is 0)
+ return created;
+
+ var organizations = await _organizationRepository.GetByIdsAsync(user.OrganizationIds.ToArray());
+ var withoutRecordedCreators = organizations.Where(o => o.CreatedByUserId is null && _plans.IsFreeOrTrialPlan(o.PlanId));
+ return created.Concat(withoutRecordedCreators).DistinctBy(o => o.Id).ToList();
+ }
+
+ ///
+ /// The free and trial organizations a user created, including ones they're no longer a member of. The
+ /// one-free-organization rule keeps this to a handful, so a single page is enough.
+ ///
+ private async Task> GetCreatedFreeOrTrialOrganizationsAsync(string userId)
+ {
+ var results = await _organizationRepository.FindAsync(q => q
+ .FieldEquals(o => o.CreatedByUserId, userId)
+ .FieldEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id), o => o.PageLimit(100));
+ return results.Documents;
+ }
+
+ ///
+ /// Counts an invite from a Free or trial organization toward the inviter's daily limit, and returns whether it's
+ /// within .
+ ///
+ public async Task TryCountFreeInviteAsync(string userId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userId);
+
+ var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
+ long invites = await _cacheClient.IncrementAsync(GetFreeInviteCountCacheKey(userId, utcNow), 1, utcNow.Date.AddDays(1));
+ return invites <= _billingOptions.FreeInviteDailyLimit;
+ }
+
+ private static string GetFreeInviteCountCacheKey(string userId, DateTime utcNow)
+ => $"User:{userId}:free-invites:{utcNow:yyyyMMdd}";
+
+ private static string GetOwnerFreeOrganizationLockKey(string userId)
+ => $"User:{userId}:free-organization";
+
+ private string GetTrialEmailLockKey(string emailAddress)
+ => $"Trial:{TrialEmailAddress.GetClaimId(emailAddress, _billingOptions.TrialClaimSecret!)}";
+
private static string GetOrganizationLockKey(string organizationId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(organizationId);
+
return $"Organization:{organizationId}:billing-plan";
}
}
diff --git a/src/Exceptionless.Core/Billing/BillingPlans.cs b/src/Exceptionless.Core/Billing/BillingPlans.cs
index 648a2745e0..ed44c79842 100644
--- a/src/Exceptionless.Core/Billing/BillingPlans.cs
+++ b/src/Exceptionless.Core/Billing/BillingPlans.cs
@@ -6,6 +6,15 @@ public class BillingPlans
{
public BillingPlans(AppOptions options)
{
+ var trialAssistantOptions = new AssistantPlanOptions
+ {
+ MaximumConcurrentTurns = 1,
+ MaximumTurnsPerMinute = 5,
+ MaximumMonthlyTokens = 5_000_000,
+ MaximumMonthlyCostUsd = 1m,
+ SharedDailyCostLimitKey = "trial",
+ MaximumSharedDailyCostUsd = options.BillingOptions?.TrialAssistantDailyCostLimitUsd ?? 0
+ };
var mediumAssistantOptions = new AssistantPlanOptions
{
MaximumConcurrentTurns = 2,
@@ -55,14 +64,29 @@ public BillingPlans(AppOptions options)
HasPremiumFeatures = false
};
+ TrialPlan = new BillingPlan
+ {
+ Id = "EX_TRIAL",
+ Name = "Trial",
+ Description = "Free Trial",
+ IsHidden = true,
+ Price = 0,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
+ RetentionDays = 90,
+ MaxEventsPerMonth = 75000,
+ HasPremiumFeatures = true,
+ Assistant = trialAssistantOptions
+ };
+
SmallPlan = new BillingPlan
{
Id = "EX_SMALL",
Name = "Small",
Description = "Small ($15/month)",
Price = 15,
- MaxProjects = 5,
- MaxUsers = 10,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 30,
MaxEventsPerMonth = 15000,
HasPremiumFeatures = true
@@ -74,8 +98,8 @@ public BillingPlans(AppOptions options)
Name = "Small (Yearly)",
Description = "Small Yearly ($165/year - Save $15)",
Price = 165,
- MaxProjects = 5,
- MaxUsers = 10,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 30,
MaxEventsPerMonth = 15000,
HasPremiumFeatures = true
@@ -87,8 +111,8 @@ public BillingPlans(AppOptions options)
Name = "Medium",
Description = "Medium ($49/month)",
Price = 49,
- MaxProjects = 15,
- MaxUsers = 25,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 90,
MaxEventsPerMonth = 75000,
HasPremiumFeatures = true,
@@ -101,8 +125,8 @@ public BillingPlans(AppOptions options)
Name = "Medium (Yearly)",
Description = "Medium Yearly ($539/year - Save $49)",
Price = 539,
- MaxProjects = 15,
- MaxUsers = 25,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 90,
MaxEventsPerMonth = 75000,
HasPremiumFeatures = true,
@@ -115,8 +139,8 @@ public BillingPlans(AppOptions options)
Name = "Large",
Description = "Large ($99/month)",
Price = 99,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 250000,
HasPremiumFeatures = true,
@@ -129,8 +153,8 @@ public BillingPlans(AppOptions options)
Name = "Large (Yearly)",
Description = "Large Yearly ($1,089/year - Save $99)",
Price = 1089,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 250000,
HasPremiumFeatures = true,
@@ -143,8 +167,8 @@ public BillingPlans(AppOptions options)
Name = "Extra Large",
Description = "Extra Large ($199/month)",
Price = 199,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 1000000,
HasPremiumFeatures = true,
@@ -157,8 +181,8 @@ public BillingPlans(AppOptions options)
Name = "Extra Large (Yearly)",
Description = "Extra Large Yearly ($2,189/year - Save $199)",
Price = 2189,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 1000000,
HasPremiumFeatures = true,
@@ -171,8 +195,8 @@ public BillingPlans(AppOptions options)
Name = "Enterprise",
Description = "Enterprise ($499/month)",
Price = 499,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 3000000,
HasPremiumFeatures = true,
@@ -185,8 +209,8 @@ public BillingPlans(AppOptions options)
Name = "Enterprise (Yearly)",
Description = "Enterprise Yearly ($5,489/year - Save $499)",
Price = 5489,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = 180,
MaxEventsPerMonth = 3000000,
HasPremiumFeatures = true,
@@ -200,19 +224,24 @@ public BillingPlans(AppOptions options)
Description = "Unlimited",
IsHidden = true,
Price = 0,
- MaxProjects = -1,
- MaxUsers = -1,
+ MaxProjects = BillingPlan.Unlimited,
+ MaxUsers = BillingPlan.Unlimited,
RetentionDays = options.MaximumRetentionDays,
MaxEventsPerMonth = -1,
HasPremiumFeatures = true,
Assistant = unlimitedAssistantOptions
};
- Plans = new List { FreePlan, SmallYearlyPlan, MediumYearlyPlan, LargeYearlyPlan, ExtraLargeYearlyPlan, EnterpriseYearlyPlan, SmallPlan, MediumPlan, LargePlan, ExtraLargePlan, EnterprisePlan, UnlimitedPlan };
+ Plans = new List { FreePlan, TrialPlan, SmallYearlyPlan, MediumYearlyPlan, LargeYearlyPlan, ExtraLargeYearlyPlan, EnterpriseYearlyPlan, SmallPlan, MediumPlan, LargePlan, ExtraLargePlan, EnterprisePlan, UnlimitedPlan };
}
public BillingPlan FreePlan { get; }
+ ///
+ /// Hidden no-card trial with Medium plan features. Trial organizations move to the free plan when the trial ends.
+ ///
+ public BillingPlan TrialPlan { get; }
+
public BillingPlan SmallPlan { get; }
public BillingPlan SmallYearlyPlan { get; }
@@ -237,6 +266,12 @@ public BillingPlans(AppOptions options)
public List Plans { get; }
+ public bool IsFreePlan(string? planId) => String.Equals(planId, FreePlan.Id, StringComparison.OrdinalIgnoreCase);
+
+ public bool IsTrialPlan(string? planId) => String.Equals(planId, TrialPlan.Id, StringComparison.OrdinalIgnoreCase);
+
+ public bool IsFreeOrTrialPlan(string? planId) => IsFreePlan(planId) || IsTrialPlan(planId);
+
public BillingPlan? GetPlan(string? planId) => planId is null
? null
: Plans.FirstOrDefault(plan => String.Equals(plan.Id, planId, StringComparison.OrdinalIgnoreCase));
diff --git a/src/Exceptionless.Core/Billing/TrialEmailAddress.cs b/src/Exceptionless.Core/Billing/TrialEmailAddress.cs
new file mode 100644
index 0000000000..95d34daf96
--- /dev/null
+++ b/src/Exceptionless.Core/Billing/TrialEmailAddress.cs
@@ -0,0 +1,212 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Exceptionless.Core.Billing;
+
+///
+/// Normalizes email addresses for the one-trial-per-mailbox rule and checks them against blocked disposable domains.
+///
+public static class TrialEmailAddress
+{
+ private static readonly HashSet _dotInsensitiveDomains = new(StringComparer.Ordinal) { "gmail.com", "googlemail.com" };
+
+ ///
+ /// Disposable email providers blocked from trials until a system admin saves their own list.
+ ///
+ public static IReadOnlyList DefaultBlockedDomains { get; } =
+ [
+ "10mail.org",
+ "10minutemail.com",
+ "10minutemail.de",
+ "10minutemail.info",
+ "10minutemail.net",
+ "10minutemail.org",
+ "10minutes.email",
+ "1secmail.com",
+ "1secmail.net",
+ "1secmail.org",
+ "20mail.it",
+ "20minutemail.com",
+ "24mail.chacuo.net",
+ "33mail.com",
+ "binkmail.com",
+ "byom.de",
+ "chacuo.net",
+ "crazymailing.com",
+ "discard.email",
+ "discardmail.com",
+ "disposablemail.com",
+ "dispostable.com",
+ "dropmail.me",
+ "emailfake.com",
+ "emailnator.com",
+ "emailondeck.com",
+ "emltmp.com",
+ "fake-mail.net",
+ "fakeinbox.com",
+ "fakemail.net",
+ "femailtor.com",
+ "getairmail.com",
+ "getnada.com",
+ "grr.la",
+ "guerrillamail.biz",
+ "guerrillamail.com",
+ "guerrillamail.de",
+ "guerrillamail.info",
+ "guerrillamail.net",
+ "guerrillamail.org",
+ "guerrillamailblock.com",
+ "harakirimail.com",
+ "inboxkitten.com",
+ "instaddr.ch",
+ "instaddr.uk",
+ "instaddr.win",
+ "jourrapide.com",
+ "kuku.lu",
+ "linshiyou.com",
+ "linshiyouxiang.net",
+ "mail-temp.com",
+ "mail.gw",
+ "mail.tm",
+ "mailcatch.com",
+ "maildrop.cc",
+ "mailinator.com",
+ "mailinator.net",
+ "mailinator.org",
+ "mailinator2.com",
+ "mailme24.com",
+ "mailna.co",
+ "mailna.me",
+ "mailnesia.com",
+ "mailsac.com",
+ "mintemail.com",
+ "minuteinbox.com",
+ "minutemail.com",
+ "moakt.cc",
+ "moakt.com",
+ "mohmal.com",
+ "mohmal.in",
+ "mytemp.email",
+ "mytrashmail.com",
+ "nada.email",
+ "nada.ltd",
+ "nospamfor.us",
+ "one-time.email",
+ "pookmail.com",
+ "psnator.com",
+ "rhyta.com",
+ "safetymail.info",
+ "sharklasers.com",
+ "smartnator.com",
+ "spamavert.com",
+ "spambog.com",
+ "spamcowboy.com",
+ "spamgourmet.com",
+ "spamherelots.com",
+ "temp-mail.io",
+ "temp-mail.org",
+ "temp.email",
+ "tempail.com",
+ "tempforward.com",
+ "tempinbox.com",
+ "tempmail.cn",
+ "tempmail.com",
+ "tempmail.dev",
+ "tempmail.net",
+ "tempmail.org",
+ "tempmail.plus",
+ "tempmailo.com",
+ "tempr.email",
+ "throwaway.email",
+ "throwawaymail.com",
+ "tmail.ws",
+ "tmailor.com",
+ "tmpbox.net",
+ "tmpeml.com",
+ "tmpmail.net",
+ "tmpmail.org",
+ "tmpmailtor.com",
+ "trash-mail.com",
+ "trashmail.com",
+ "trashmail.net",
+ "trashmail.org",
+ "trashmail.ws",
+ "vtmpj.com",
+ "vtmpj.net",
+ "yopmail.com",
+ "yopmail.fr",
+ "yopmail.net",
+ "youxiang.dev",
+ "zhimail.xyz"
+ ];
+
+ ///
+ /// Lowercases the address and removes the parts mail providers ignore: a +tag on any domain, and dots on
+ /// Gmail, so aliases of one mailbox normalize to the same address.
+ ///
+ public static string Normalize(string emailAddress)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);
+
+ string address = emailAddress.Trim().ToLowerInvariant();
+ int at = address.LastIndexOf('@');
+ if (at is <= 0 || at == address.Length - 1)
+ return address;
+
+ string localPart = address[..at];
+ string domain = address[(at + 1)..];
+
+ int plus = localPart.IndexOf('+');
+ if (plus is > 0)
+ localPart = localPart[..plus];
+
+ if (_dotInsensitiveDomains.Contains(domain))
+ {
+ localPart = localPart.Replace(".", String.Empty);
+ domain = "gmail.com";
+ }
+
+ return $"{localPart}@{domain}";
+ }
+
+ ///
+ /// Identifies a normalized mailbox using a dedicated HMAC secret without retaining the address or unkeyed digest.
+ ///
+ public static string GetClaimId(string emailAddress, string secret)
+ {
+ byte[] key = Convert.FromBase64String(secret);
+ if (key.Length is < 32)
+ throw new ArgumentException("The trial claim secret must contain at least 32 random bytes", nameof(secret));
+
+ byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(Normalize(emailAddress)));
+ return "hmac-v1-" + Convert.ToHexStringLower(HMACSHA256.HashData(key, digest));
+ }
+
+ ///
+ /// Whether the address is on a blocked domain or one of its subdomains.
+ ///
+ public static bool IsBlockedDomain(string emailAddress, IEnumerable blockedDomains)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);
+ ArgumentNullException.ThrowIfNull(blockedDomains);
+
+ string address = emailAddress.Trim().ToLowerInvariant();
+ int at = address.LastIndexOf('@');
+ if (at is < 0 || at == address.Length - 1)
+ return false;
+
+ string domain = address[(at + 1)..];
+ return blockedDomains.Any(blocked => String.Equals(domain, blocked, StringComparison.Ordinal) || domain.EndsWith($".{blocked}", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Trims, lowercases, removes a leading @, drops blanks and duplicates, and sorts a domain list for saving.
+ ///
+ public static List NormalizeDomains(IEnumerable? domains)
+ => (domains ?? [])
+ .Select(domain => domain.Trim().TrimStart('@').ToLowerInvariant())
+ .Where(domain => domain.Length is > 0)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToList();
+}
diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs
index 0d45dc5823..ee36fdf0d6 100644
--- a/src/Exceptionless.Core/Bootstrapper.cs
+++ b/src/Exceptionless.Core/Bootstrapper.cs
@@ -156,6 +156,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -523,6 +524,7 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log
services.AddDistributedCronJob("45 */8 * * *");
services.AddDistributedCronJob(Cron.Daily(1));
services.AddDistributedCronJob("10 */2 * * *");
+ services.AddDistributedCronJob("15 * * * *");
var logger = loggerFactory.CreateLogger();
logger.LogWarning("Jobs running in process");
diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs
index a0528ec823..bdb923ba1f 100644
--- a/src/Exceptionless.Core/Configuration/AppOptions.cs
+++ b/src/Exceptionless.Core/Configuration/AppOptions.cs
@@ -73,19 +73,20 @@ public class AppOptions
public int BulkBatchSize { get; internal set; }
+ public AssistantOptions AssistantOptions { get; internal set; } = null!;
+ public AuthOptions AuthOptions { get; internal set; } = null!;
+ public BillingOptions BillingOptions { get; internal set; } = null!;
public CacheOptions CacheOptions { get; internal set; } = null!;
- public MessageBusOptions MessageBusOptions { get; internal set; } = null!;
- public QueueOptions QueueOptions { get; internal set; } = null!;
- public StorageOptions StorageOptions { get; internal set; } = null!;
- public EmailOptions EmailOptions { get; internal set; } = null!;
public ElasticsearchOptions ElasticsearchOptions { get; internal set; } = null!;
+ public EmailOptions EmailOptions { get; internal set; } = null!;
public IntercomOptions IntercomOptions { get; internal set; } = null!;
- public SlackOptions SlackOptions { get; internal set; } = null!;
- public StripeOptions StripeOptions { get; internal set; } = null!;
- public AuthOptions AuthOptions { get; internal set; } = null!;
+ public MessageBusOptions MessageBusOptions { get; internal set; } = null!;
public OAuthServerOptions OAuthServerOptions { get; internal set; } = null!;
+ public QueueOptions QueueOptions { get; internal set; } = null!;
+ public SlackOptions SlackOptions { get; internal set; } = null!;
public SourceMapOptions SourceMapOptions { get; internal set; } = null!;
- public AssistantOptions AssistantOptions { get; internal set; } = null!;
+ public StorageOptions StorageOptions { get; internal set; } = null!;
+ public StripeOptions StripeOptions { get; internal set; } = null!;
public static AppOptions ReadFromConfiguration(IConfiguration config)
{
@@ -130,19 +131,23 @@ public static AppOptions ReadFromConfiguration(IConfiguration config)
}
catch { }
+ options.AssistantOptions = AssistantOptions.ReadFromConfiguration(config);
+ options.AuthOptions = AuthOptions.ReadFromConfiguration(config);
+ options.BillingOptions = BillingOptions.ReadFromConfiguration(config);
options.CacheOptions = CacheOptions.ReadFromConfiguration(config, options);
- options.MessageBusOptions = MessageBusOptions.ReadFromConfiguration(config, options);
- options.QueueOptions = QueueOptions.ReadFromConfiguration(config, options);
- options.StorageOptions = StorageOptions.ReadFromConfiguration(config, options);
- options.EmailOptions = EmailOptions.ReadFromConfiguration(config, options);
options.ElasticsearchOptions = ElasticsearchOptions.ReadFromConfiguration(config, options);
+ options.EmailOptions = EmailOptions.ReadFromConfiguration(config, options);
options.IntercomOptions = IntercomOptions.ReadFromConfiguration(config);
- options.SlackOptions = SlackOptions.ReadFromConfiguration(config);
- options.StripeOptions = StripeOptions.ReadFromConfiguration(config);
- options.AuthOptions = AuthOptions.ReadFromConfiguration(config);
+ options.MessageBusOptions = MessageBusOptions.ReadFromConfiguration(config, options);
options.OAuthServerOptions = OAuthServerOptions.ReadFromConfiguration(config);
+ options.QueueOptions = QueueOptions.ReadFromConfiguration(config, options);
+ options.SlackOptions = SlackOptions.ReadFromConfiguration(config);
options.SourceMapOptions = SourceMapOptions.ReadFromConfiguration(config);
- options.AssistantOptions = AssistantOptions.ReadFromConfiguration(config);
+ options.StorageOptions = StorageOptions.ReadFromConfiguration(config, options);
+ options.StripeOptions = StripeOptions.ReadFromConfiguration(config);
+
+ if (options.AppMode is not AppMode.Development && options.StripeOptions.EnableBilling && options.BillingOptions.TrialDays is > 0 && options.BillingOptions.TrialClaimSecret is null)
+ throw new InvalidOperationException("Billing:TrialClaimSecret is required when hosted trials are enabled");
return options;
}
diff --git a/src/Exceptionless.Core/Configuration/BillingOptions.cs b/src/Exceptionless.Core/Configuration/BillingOptions.cs
new file mode 100644
index 0000000000..263a2c6bc9
--- /dev/null
+++ b/src/Exceptionless.Core/Configuration/BillingOptions.cs
@@ -0,0 +1,68 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Exceptionless.Core.Configuration;
+
+public class BillingOptions
+{
+ ///
+ /// Length of the no-card trial a user's first organization receives. Zero disables trials.
+ ///
+ public int TrialDays { get; internal set; } = 14;
+
+ ///
+ /// Days before a trial ends to send the trial ending reminder email.
+ ///
+ public int TrialReminderDays { get; internal set; } = 3;
+
+ ///
+ /// Combined daily Exie cost limit across all trial organizations. Zero or less disables the limit.
+ ///
+ public decimal TrialAssistantDailyCostLimitUsd { get; internal set; } = 25m;
+
+ ///
+ /// Maximum organization invites a user can send per UTC day from free or trial organizations.
+ ///
+ public int FreeInviteDailyLimit { get; internal set; } = 10;
+
+ ///
+ /// Dedicated base64-encoded secret of at least 32 random bytes for mailbox trial claims. Keep stable across hosts and deployments.
+ ///
+ public string? TrialClaimSecret { get; internal set; }
+
+ ///
+ /// Days a mailbox's trial claim blocks another trial. After that the mailbox can trial again and the claim is removed.
+ ///
+ public int TrialClaimRetentionDays { get; internal set; } = 365;
+
+ public static BillingOptions ReadFromConfiguration(IConfiguration config)
+ {
+ var options = new BillingOptions();
+ options.TrialDays = Math.Max(0, config.GetValue("Billing:TrialDays", 14));
+ options.TrialReminderDays = Math.Max(0, config.GetValue("Billing:TrialReminderDays", 3));
+ options.TrialAssistantDailyCostLimitUsd = config.GetValue("Billing:TrialAssistantDailyCostLimitUsd", 25m);
+ options.FreeInviteDailyLimit = Math.Max(0, config.GetValue("Billing:FreeInviteDailyLimit", 10));
+ options.TrialClaimRetentionDays = Math.Max(1, config.GetValue("Billing:TrialClaimRetentionDays", 365));
+ options.TrialClaimSecret = config.GetValue("Billing:TrialClaimSecret");
+ if (!String.IsNullOrEmpty(options.TrialClaimSecret))
+ {
+ byte[] key;
+ try
+ {
+ key = Convert.FromBase64String(options.TrialClaimSecret);
+ }
+ catch (FormatException)
+ {
+ throw new InvalidOperationException("Billing:TrialClaimSecret must be base64-encoded random bytes");
+ }
+
+ if (key.Length is < 32)
+ throw new InvalidOperationException("Billing:TrialClaimSecret must contain at least 32 random bytes");
+ }
+ else
+ {
+ options.TrialClaimSecret = null;
+ }
+
+ return options;
+ }
+}
diff --git a/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs
new file mode 100644
index 0000000000..f62d8bdfd2
--- /dev/null
+++ b/src/Exceptionless.Core/Jobs/TrialExpirationJob.cs
@@ -0,0 +1,166 @@
+using Exceptionless.Core.Billing;
+using Exceptionless.Core.Configuration;
+using Exceptionless.Core.Mail;
+using Exceptionless.Core.Messaging.Models;
+using Exceptionless.Core.Models;
+using Exceptionless.Core.Repositories;
+using Exceptionless.DateTimeExtensions;
+using Foundatio.Jobs;
+using Foundatio.Lock;
+using Foundatio.Messaging;
+using Foundatio.Repositories;
+using Foundatio.Resilience;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+
+namespace Exceptionless.Core.Jobs;
+
+///
+/// Sends the trial ending reminder, moves organizations whose no-card trial has ended to the free plan, and removes
+/// expired mailbox trial claims.
+///
+[Job(Description = "Sends trial reminders and moves expired trials to the free plan.", IsContinuous = false)]
+public class TrialExpirationJob : JobWithLockBase, IHealthCheck
+{
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly IUserRepository _userRepository;
+ private readonly BillingManager _billingManager;
+ private readonly BillingPlans _plans;
+ private readonly BillingOptions _billingOptions;
+ private readonly IMailer _mailer;
+ private readonly IMessagePublisher _messagePublisher;
+ private readonly ILockProvider _lockProvider;
+ private DateTime? _lastRunUtc;
+
+ public TrialExpirationJob(IOrganizationRepository organizationRepository, IUserRepository userRepository,
+ BillingManager billingManager, BillingPlans plans, AppOptions appOptions, IMailer mailer,
+ IMessagePublisher messagePublisher, ILockProvider lockProvider,
+ TimeProvider timeProvider,
+ IResiliencePolicyProvider resiliencePolicyProvider,
+ ILoggerFactory loggerFactory
+ ) : base(timeProvider, resiliencePolicyProvider, loggerFactory)
+ {
+ _organizationRepository = organizationRepository;
+ _userRepository = userRepository;
+ _billingManager = billingManager;
+ _plans = plans;
+ _billingOptions = appOptions.BillingOptions;
+ _mailer = mailer;
+ _messagePublisher = messagePublisher;
+ _lockProvider = lockProvider;
+ }
+
+ protected override Task GetLockAsync(CancellationToken cancellationToken = default)
+ {
+ return _lockProvider.TryAcquireAsync(nameof(TrialExpirationJob), TimeSpan.FromMinutes(30), cancellationToken);
+ }
+
+ protected override async Task RunInternalAsync(JobContext context)
+ {
+ _lastRunUtc = _timeProvider.GetUtcNow().UtcDateTime;
+ context.CancellationToken.ThrowIfCancellationRequested();
+ int processed = 0;
+ int failed = 0;
+
+ try
+ {
+ long removed = await _billingManager.RemoveExpiredTrialClaimsAsync();
+ if (removed is > 0)
+ _logger.LogInformation("Removed {Count} expired trial claims", removed);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Expired claims don't block trials, so a failed cleanup only delays removal until the next run.
+ _logger.LogError(ex, "Error removing expired trial claims: {Message}", ex.Message);
+ }
+
+ var results = await _organizationRepository.FindAsync(q => q
+ .FieldEquals(o => o.PlanId, _plans.TrialPlan.Id)
+ .OnlyIds(), o => o.SearchAfterPaging().PageLimit(100));
+
+ while (results.Hits.Count is > 0 && !context.CancellationToken.IsCancellationRequested)
+ {
+ foreach (string organizationId in results.Hits.Where(h => h.Id is not null).Select(h => h.Id!))
+ {
+ using var scope = _logger.BeginScope(new ExceptionlessState().Organization(organizationId));
+ if (context.CancellationToken.IsCancellationRequested)
+ return JobResult.Cancelled;
+
+ try
+ {
+ await ProcessOrganizationAsync(organizationId);
+ processed++;
+ }
+ catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
+ {
+ // Stop here instead of fetching the next page and renewing the lock; the next run picks up the rest.
+ return JobResult.Cancelled;
+ }
+ catch (Exception ex)
+ {
+ failed++;
+ _logger.LogError(ex, "Error processing trial for organization {OrganizationId}: {Message}", organizationId, ex.Message);
+ }
+ }
+
+ if (context.CancellationToken.IsCancellationRequested)
+ break;
+
+ bool hasNextPage = await results.NextPageAsync();
+ if (!hasNextPage)
+ break;
+
+ await context.RenewLockAsync();
+ }
+
+ _logger.LogInformation("Processed {ProcessedCount} trial organizations with {FailedCount} failures", processed, failed);
+ return context.CancellationToken.IsCancellationRequested ? JobResult.Cancelled : JobResult.Success;
+ }
+
+ private async Task ProcessOrganizationAsync(string organizationId)
+ {
+ await using var billingLock = await _billingManager.AcquireOrganizationLockAsync(organizationId);
+ // The billing lock serializes writers, but a stale cache entry can still predate a trial extension.
+ var organization = await _organizationRepository.GetByIdAsync(organizationId);
+ if (organization is null || !_plans.IsTrialPlan(organization.PlanId))
+ return;
+
+ var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
+ if (organization.TrialEndsUtc is null || !organization.TrialEndsUtc.Value.IsAfter(utcNow))
+ {
+ _logger.LogInformation("Trial ended for organization {OrganizationId}, moving to the free plan", organization.Id);
+ _billingManager.EndTrial(organization);
+ await _billingManager.UpdatePlanLimitSuspensionAsync(organization);
+ await _organizationRepository.SaveAsync(organization, o => o.Cache().Originals());
+ await _messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id });
+ return;
+ }
+
+ if (organization.TrialReminderSentUtc is not null || organization.TrialEndsUtc.Value.AddDays(-_billingOptions.TrialReminderDays).IsAfter(utcNow))
+ return;
+
+ var users = await _userRepository.GetByOrganizationIdAsync(organization.Id, o => o.SearchAfterPaging().PageLimit(100));
+ do
+ {
+ foreach (var user in users.Documents.Where(u => u.IsEmailAddressVerified && u.EmailNotificationsEnabled))
+ await _mailer.SendOrganizationTrialEndingAsync(user, organization);
+ } while (await users.NextPageAsync());
+
+ // Mark the reminder only after every email is queued, so a failure is retried on the next run.
+ organization.TrialReminderSentUtc = utcNow;
+ await _organizationRepository.SaveAsync(organization, o => o.Cache().Originals());
+
+ _logger.LogInformation("Sent trial ending reminder for organization {OrganizationId}", organization.Id);
+ }
+
+ public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ if (!_lastRunUtc.HasValue)
+ return Task.FromResult(HealthCheckResult.Healthy("Job has not been run yet."));
+
+ if (_timeProvider.GetUtcNow().UtcDateTime.Subtract(_lastRunUtc.Value) > TimeSpan.FromMinutes(65))
+ return Task.FromResult(HealthCheckResult.Unhealthy("Job has not run in the last 65 minutes."));
+
+ return Task.FromResult(HealthCheckResult.Healthy("Job has run in the last 65 minutes."));
+ }
+}
diff --git a/src/Exceptionless.Core/Mail/IMailer.cs b/src/Exceptionless.Core/Mail/IMailer.cs
index 6e8905cab5..57105c6478 100644
--- a/src/Exceptionless.Core/Mail/IMailer.cs
+++ b/src/Exceptionless.Core/Mail/IMailer.cs
@@ -10,6 +10,7 @@ public interface IMailer
Task SendOrganizationInviteAsync(User sender, Organization organization, Invite invite);
Task SendOrganizationNoticeAsync(User user, Organization organization, bool isOverMonthlyLimit, bool isOverHourlyLimit);
Task SendOrganizationPaymentFailedAsync(User owner, Organization organization);
+ Task SendOrganizationTrialEndingAsync(User user, Organization organization);
Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan);
Task SendUserEmailVerifyAsync(User user);
Task SendUserPasswordResetAsync(User user);
diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs
index 85933fb25a..d36cb1254b 100644
--- a/src/Exceptionless.Core/Mail/Mailer.cs
+++ b/src/Exceptionless.Core/Mail/Mailer.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.Core.Plugins.Formatting;
@@ -248,6 +249,27 @@ await QueueMessageAsync(new MailMessage
}, template);
}
+ public async Task SendOrganizationTrialEndingAsync(User user, Organization organization)
+ {
+ ArgumentNullException.ThrowIfNull(organization.TrialEndsUtc);
+
+ const string template = "organization-trial-ending";
+ string subject = $"[{organization.Name}] Your free trial ends soon";
+ var message = new OrganizationTrialEndingEmail(
+ subject,
+ organization.Name,
+ organization.TrialEndsUtc.Value.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture),
+ _appUrls.OrganizationUpgrade(organization.Id));
+
+ string body = await _templateRenderer.RenderAsync(message);
+ await QueueMessageAsync(new MailMessage
+ {
+ To = user.EmailAddress,
+ Subject = subject,
+ Body = body
+ }, template);
+ }
+
public async Task SendProjectDailySummaryAsync(User user, Project project, IEnumerable? mostFrequent, IEnumerable? newest, DateTime startDate, bool hasSubmittedEvents, double count, double uniqueCount, double newCount, double fixedCount, int blockedCount, int tooBigCount, bool isFreePlan)
{
const string template = "project-daily-summary";
diff --git a/src/Exceptionless.Core/Migrations/011_UpdateOrganizationPlanLimits.cs b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationPlanLimits.cs
new file mode 100644
index 0000000000..6364db9f2c
--- /dev/null
+++ b/src/Exceptionless.Core/Migrations/011_UpdateOrganizationPlanLimits.cs
@@ -0,0 +1,81 @@
+using Exceptionless.Core.Models;
+using Exceptionless.Core.Billing;
+using Exceptionless.Core.Repositories;
+using Exceptionless.Core.Repositories.Configuration;
+using Foundatio.Repositories;
+using Foundatio.Repositories.Elasticsearch.Extensions;
+using Foundatio.Repositories.Migrations;
+using Foundatio.Repositories.Models;
+using Microsoft.Extensions.Logging;
+
+namespace Exceptionless.Core.Migrations;
+
+///
+/// Maps the creator field and refreshes stored user/project limits from each organization's current plan.
+/// Existing organizations without a recorded creator retain membership-based eligibility.
+///
+public sealed class UpdateOrganizationPlanLimits : MigrationBase
+{
+ private const int BatchSize = 100;
+ private readonly ExceptionlessElasticConfiguration _configuration;
+ private readonly IOrganizationRepository _organizationRepository;
+ private readonly BillingPlans _plans;
+
+ public UpdateOrganizationPlanLimits(IOrganizationRepository organizationRepository, ExceptionlessElasticConfiguration configuration, BillingPlans plans, ILoggerFactory loggerFactory) : base(loggerFactory)
+ {
+ _organizationRepository = organizationRepository;
+ _configuration = configuration;
+ _plans = plans;
+
+ MigrationType = MigrationType.VersionedAndResumable;
+ Version = 11;
+ }
+
+ public override async Task RunAsync(MigrationContext context)
+ {
+ var mappingResponse = await _configuration.Client.Indices.PutMappingAsync(mapping => mapping
+ .Indices(_configuration.Organizations.VersionedName)
+ .Properties(properties => properties.Keyword(organization => organization.CreatedByUserId)), context.CancellationToken);
+ _logger.LogRequest(mappingResponse);
+ if (!mappingResponse.IsValidResponse)
+ throw new InvalidOperationException("Unable to map the organization created by user id.");
+
+ var organizations = await _organizationRepository.FindAsync(
+ query => query.SortAscending(organization => organization.Id),
+ options => options
+ .SoftDeleteMode(SoftDeleteQueryMode.All)
+ .SearchAfterPaging()
+ .PageLimit(BatchSize));
+
+ int migrated = 0;
+ while (organizations.Documents.Count is > 0)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ // Resolve the current plan inside the update, so a concurrent billing change isn't overwritten.
+ var limits = _plans.Plans.ToDictionary(plan => plan.Id,
+ plan => new { max_users = plan.MaxUsers, max_projects = plan.MaxProjects });
+ var patch = new ScriptPatch("""
+ if (params.limits.containsKey(ctx._source.plan_id)) {
+ def limits = params.limits[ctx._source.plan_id];
+ ctx._source.max_users = limits.max_users;
+ ctx._source.max_projects = limits.max_projects;
+ }
+ """)
+ {
+ Params = new Dictionary { ["limits"] = limits }
+ };
+ await _organizationRepository.PatchAsync(
+ organizations.Documents.Select(organization => organization.Id).ToArray(), patch,
+ options => options.Cache().Notifications(false));
+ migrated += organizations.Documents.Count;
+
+ _logger.LogInformation("Refreshed plan limits for {OrganizationCount} organizations so far", migrated);
+ bool hasNextPage = await organizations.NextPageAsync();
+ if (!hasNextPage)
+ break;
+ }
+
+ _logger.LogInformation("Refreshed plan limits for {OrganizationCount} organizations", migrated);
+ }
+}
diff --git a/src/Exceptionless.Core/Models/Billing/BillingPlan.cs b/src/Exceptionless.Core/Models/Billing/BillingPlan.cs
index aedc323cb4..cadcd8afe5 100644
--- a/src/Exceptionless.Core/Models/Billing/BillingPlan.cs
+++ b/src/Exceptionless.Core/Models/Billing/BillingPlan.cs
@@ -6,6 +6,11 @@ namespace Exceptionless.Core.Models.Billing;
[DebuggerDisplay("Id: {Id} Name: {Name} Price: {Price}")]
public record BillingPlan
{
+ ///
+ /// Limit value that means no limit.
+ ///
+ public const int Unlimited = -1;
+
public required string Id { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
@@ -27,4 +32,14 @@ public sealed record AssistantPlanOptions
public int MaximumTurnsPerMinute { get; init; }
public long MaximumMonthlyTokens { get; init; }
public decimal MaximumMonthlyCostUsd { get; init; }
+
+ ///
+ /// Groups organizations whose combined daily Exie cost is limited by .
+ ///
+ public string? SharedDailyCostLimitKey { get; init; }
+
+ ///
+ /// Combined daily Exie cost limit for every organization sharing . Zero or less disables the limit.
+ ///
+ public decimal MaximumSharedDailyCostUsd { get; init; }
}
diff --git a/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs b/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs
index 61013014f7..8c0d0791d8 100644
--- a/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs
+++ b/src/Exceptionless.Core/Models/Billing/BillingPlanStats.cs
@@ -13,6 +13,9 @@ public record BillingPlanStats
public int MonthlyTotalAccounts { get; init; }
public int YearlyTotalAccounts { get; init; }
public int FreeAccounts { get; init; }
+ public int TrialAccounts { get; init; }
+ public int ConvertedTrialAccounts { get; init; }
+ public int ExpiredTrialAccounts { get; init; }
public int PaidAccounts { get; init; }
public int FreeloaderAccounts { get; init; }
public int SuspendedAccounts { get; init; }
diff --git a/src/Exceptionless.Core/Models/Enums/SuspensionCode.cs b/src/Exceptionless.Core/Models/Enums/SuspensionCode.cs
index 631a5ce048..6edbff5221 100644
--- a/src/Exceptionless.Core/Models/Enums/SuspensionCode.cs
+++ b/src/Exceptionless.Core/Models/Enums/SuspensionCode.cs
@@ -5,5 +5,6 @@ public enum SuspensionCode
Billing,
Overage,
Abuse,
+ PlanLimit,
Other = 100
}
diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs
index 4cfa22dac8..68b4df9e39 100644
--- a/src/Exceptionless.Core/Models/Organization.cs
+++ b/src/Exceptionless.Core/Models/Organization.cs
@@ -77,6 +77,23 @@ public Organization()
///
public DateTime? SubscribeDate { get; set; }
+ ///
+ /// User who created the organization. Used to enforce per-owner plan rules such as one free organization.
+ ///
+ [ObjectId]
+ public string? CreatedByUserId { get; set; }
+
+ ///
+ /// Date the organization's no-card trial ends. Kept after the trial ends so the app can tell the
+ /// organization its trial is over.
+ ///
+ public DateTime? TrialEndsUtc { get; set; }
+
+ ///
+ /// Date the trial ending reminder email was sent.
+ ///
+ public DateTime? TrialReminderSentUtc { get; set; }
+
///
/// Date the billing information was last changed.
///
diff --git a/src/Exceptionless.Core/Models/SystemSettings.cs b/src/Exceptionless.Core/Models/SystemSettings.cs
index 01edad4a4d..831a3c962b 100644
--- a/src/Exceptionless.Core/Models/SystemSettings.cs
+++ b/src/Exceptionless.Core/Models/SystemSettings.cs
@@ -23,6 +23,11 @@ public sealed class SystemSettings : IIdentity, IHaveDates
public SystemNotification? SystemNotification { get; set; }
+ ///
+ /// Email domains that can't start a free trial. Null uses .
+ ///
+ public List? TrialBlockedEmailDomains { get; set; }
+
[ObjectId]
public string CreatedByUserId { get; set; } = null!;
diff --git a/src/Exceptionless.Core/Models/TrialClaim.cs b/src/Exceptionless.Core/Models/TrialClaim.cs
new file mode 100644
index 0000000000..b385563af2
--- /dev/null
+++ b/src/Exceptionless.Core/Models/TrialClaim.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+using Foundatio.Repositories.Models;
+
+namespace Exceptionless.Core.Models;
+
+///
+/// Records a mailbox's latest no-card trial. It's kept apart from the user so deleting the account and
+/// signing up again doesn't make the mailbox eligible again. Kept for
+/// (12 months by default), including
+/// after user/organization deletion, then removed so the mailbox can trial again. Contains no email, user ID, or organization ID.
+///
+public sealed class TrialClaim : IIdentity, IHaveCreatedDate, IVersioned
+{
+ ///
+ /// Versioned HMAC identifier from .
+ ///
+ public string Id { get; set; } = null!;
+
+ public DateTime CreatedUtc { get; set; }
+
+ // Repository concurrency metadata is not part of the retained claim.
+ [JsonIgnore]
+ public string Version { get; set; } = String.Empty;
+}
diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs
index b234f57e25..5fb9341cb3 100644
--- a/src/Exceptionless.Core/Models/User.cs
+++ b/src/Exceptionless.Core/Models/User.cs
@@ -54,6 +54,11 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? VerifyEmailAddressToken { get; set; }
public DateTime VerifyEmailAddressTokenExpiration { get; set; }
+ ///
+ /// Date the user started their one no-card trial.
+ ///
+ public DateTime? TrialStartedUtc { get; set; }
+
///
/// Gets or sets the users active state.
///
diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs
index 837632f588..3c3d0c398b 100644
--- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs
+++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs
@@ -49,6 +49,7 @@ ILoggerFactory loggerFactory
AddIndex(SavedViews = new SavedViewIndex(this));
AddIndex(SystemSettings = new SystemSettingsIndex(this));
AddIndex(Tokens = new TokenIndex(this));
+ AddIndex(TrialClaims = new TrialClaimIndex(this));
AddIndex(Users = new UserIndex(this));
AddIndex(WebHooks = new WebHookIndex(this));
}
@@ -80,6 +81,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder)
public SavedViewIndex SavedViews { get; }
public SystemSettingsIndex SystemSettings { get; }
public TokenIndex Tokens { get; }
+ public TrialClaimIndex TrialClaims { get; }
public UserIndex Users { get; }
public WebHookIndex WebHooks { get; }
diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs
index 1e17898890..ddc305a5f6 100644
--- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs
+++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs
@@ -26,6 +26,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor m
.SetupDefaults()
.Text(e => e.Name, t => t.AddKeywordField())
.Keyword(e => e.StripeCustomerId)
+ .Keyword(e => e.CreatedByUserId)
.Boolean(e => e.HasPremiumFeatures)
.Keyword(e => e.Features)
.Keyword(e => e.PlanId)
diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/TrialClaimIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/TrialClaimIndex.cs
new file mode 100644
index 0000000000..d4b54cf863
--- /dev/null
+++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/TrialClaimIndex.cs
@@ -0,0 +1,34 @@
+using Elastic.Clients.Elasticsearch.IndexManagement;
+using Elastic.Clients.Elasticsearch.Mapping;
+using Exceptionless.Core.Models;
+using Foundatio.Repositories.Elasticsearch.Configuration;
+using Foundatio.Repositories.Elasticsearch.Extensions;
+
+namespace Exceptionless.Core.Repositories.Configuration;
+
+public sealed class TrialClaimIndex : VersionedIndex
+{
+ private readonly ExceptionlessElasticConfiguration _configuration;
+
+ public TrialClaimIndex(ExceptionlessElasticConfiguration configuration)
+ : base(configuration, configuration.Options.ScopePrefix + "trial-claims", 1)
+ {
+ _configuration = configuration;
+ }
+
+ public override void ConfigureIndexMapping(TypeMappingDescriptor map)
+ {
+ map
+ .Dynamic(DynamicMapping.False)
+ .Properties(properties => properties.SetupDefaults());
+ }
+
+ public override void ConfigureIndex(CreateIndexRequestDescriptor index)
+ {
+ base.ConfigureIndex(index);
+ index.Settings(settings => settings
+ .NumberOfShards(1)
+ .NumberOfReplicas(_configuration.Options.NumberOfReplicas)
+ .Priority(5));
+ }
+}
diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ITrialClaimRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ITrialClaimRepository.cs
new file mode 100644
index 0000000000..bfc921c1ff
--- /dev/null
+++ b/src/Exceptionless.Core/Repositories/Interfaces/ITrialClaimRepository.cs
@@ -0,0 +1,9 @@
+using Exceptionless.Core.Models;
+using Foundatio.Repositories;
+
+namespace Exceptionless.Core.Repositories;
+
+public interface ITrialClaimRepository : IRepository
+{
+ Task RemoveCreatedBeforeAsync(DateTime utcCutoff);
+}
diff --git a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs
index 07c61e0207..6dd3009d61 100644
--- a/src/Exceptionless.Core/Repositories/OrganizationRepository.cs
+++ b/src/Exceptionless.Core/Repositories/OrganizationRepository.cs
@@ -71,9 +71,9 @@ public Task> GetByCriteriaAsync(string? criteria, Comm
if (paid.HasValue)
{
if (paid.Value)
- query.FieldNotEquals(o => o.PlanId, _plans.FreePlan.Id);
+ query.FieldNotEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id);
else
- query.FieldEquals(o => o.PlanId, _plans.FreePlan.Id);
+ query.FieldEquals(o => o.PlanId, _plans.FreePlan.Id, _plans.TrialPlan.Id);
}
if (suspended.HasValue)
@@ -114,41 +114,99 @@ public Task> GetByCriteriaAsync(string? criteria, Comm
return FindAsync(q => query, options);
}
+ internal const int BillingPlanStatsPageLimit = 250;
+
public async Task GetBillingPlanStatsAsync()
{
- var results = (await FindAsync(q => q
- .Include(o => o.PlanId, o => o.IsSuspended, o => o.BillingPrice, o => o.BillingStatus)
- .SortDescending(o => o.PlanId))).Documents;
- var smallOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.SmallPlan.Id) && o.BillingPrice > 0).ToList();
- var mediumOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.MediumPlan.Id) && o.BillingPrice > 0).ToList();
- var largeOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.LargePlan.Id) && o.BillingPrice > 0).ToList();
- decimal monthlyTotalPaid = smallOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice)
- + mediumOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice)
- + largeOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice);
-
- var smallYearlyOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.SmallYearlyPlan.Id) && o.BillingPrice > 0).ToList();
- var mediumYearlyOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.MediumYearlyPlan.Id) && o.BillingPrice > 0).ToList();
- var largeYearlyOrganizations = results.Where(o => String.Equals(o.PlanId, _plans.LargeYearlyPlan.Id) && o.BillingPrice > 0).ToList();
- decimal yearlyTotalPaid = smallYearlyOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice)
- + mediumYearlyOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice)
- + largeYearlyOrganizations.Where(o => !o.IsSuspended && o.BillingStatus == BillingStatus.Active).Sum(o => o.BillingPrice);
+ var page = await FindAsync(q => q
+ .Include(o => o.PlanId, o => o.IsSuspended, o => o.BillingPrice, o => o.BillingStatus, o => o.TrialEndsUtc)
+ .SortAscending(o => o.Id), o => o.SearchAfterPaging().PageLimit(BillingPlanStatsPageLimit));
+ int smallTotal = 0, mediumTotal = 0, largeTotal = 0, smallYearlyTotal = 0, mediumYearlyTotal = 0, largeYearlyTotal = 0;
+ int freeAccounts = 0, trialAccounts = 0, convertedTrialAccounts = 0, expiredTrialAccounts = 0, paidAccounts = 0, freeloaderAccounts = 0, suspendedAccounts = 0;
+ decimal monthlyTotalPaid = 0, yearlyTotalPaid = 0;
+
+ // Count page by page so memory stays bounded by the page size, not the number of organizations.
+ bool hasNextPage;
+ do
+ {
+ foreach (var organization in page.Documents)
+ {
+ bool isFree = _plans.IsFreePlan(organization.PlanId);
+ bool isFreeOrTrial = _plans.IsFreeOrTrialPlan(organization.PlanId);
+ bool isPaying = organization.BillingPrice is > 0;
+
+ if (isFree)
+ freeAccounts++;
+ if (_plans.IsTrialPlan(organization.PlanId))
+ trialAccounts++;
+ if (organization.TrialEndsUtc is not null && !isFreeOrTrial && isPaying)
+ convertedTrialAccounts++;
+ if (organization.TrialEndsUtc is not null && isFree)
+ expiredTrialAccounts++;
+ if (!isFreeOrTrial && isPaying)
+ paidAccounts++;
+ if (!isFreeOrTrial && !isPaying)
+ freeloaderAccounts++;
+ if (organization.IsSuspended)
+ suspendedAccounts++;
+
+ if (!isPaying)
+ continue;
+
+ decimal activePrice = !organization.IsSuspended && organization.BillingStatus is BillingStatus.Active ? organization.BillingPrice : 0;
+ if (String.Equals(organization.PlanId, _plans.SmallPlan.Id, StringComparison.Ordinal))
+ {
+ smallTotal++;
+ monthlyTotalPaid += activePrice;
+ }
+ else if (String.Equals(organization.PlanId, _plans.MediumPlan.Id, StringComparison.Ordinal))
+ {
+ mediumTotal++;
+ monthlyTotalPaid += activePrice;
+ }
+ else if (String.Equals(organization.PlanId, _plans.LargePlan.Id, StringComparison.Ordinal))
+ {
+ largeTotal++;
+ monthlyTotalPaid += activePrice;
+ }
+ else if (String.Equals(organization.PlanId, _plans.SmallYearlyPlan.Id, StringComparison.Ordinal))
+ {
+ smallYearlyTotal++;
+ yearlyTotalPaid += activePrice;
+ }
+ else if (String.Equals(organization.PlanId, _plans.MediumYearlyPlan.Id, StringComparison.Ordinal))
+ {
+ mediumYearlyTotal++;
+ yearlyTotalPaid += activePrice;
+ }
+ else if (String.Equals(organization.PlanId, _plans.LargeYearlyPlan.Id, StringComparison.Ordinal))
+ {
+ largeYearlyTotal++;
+ yearlyTotalPaid += activePrice;
+ }
+ }
+ hasNextPage = await page.NextPageAsync();
+ } while (hasNextPage);
return new BillingPlanStats
{
- SmallTotal = smallOrganizations.Count,
- SmallYearlyTotal = smallYearlyOrganizations.Count,
- MediumTotal = mediumOrganizations.Count,
- MediumYearlyTotal = mediumYearlyOrganizations.Count,
- LargeTotal = largeOrganizations.Count,
- LargeYearlyTotal = largeYearlyOrganizations.Count,
+ SmallTotal = smallTotal,
+ SmallYearlyTotal = smallYearlyTotal,
+ MediumTotal = mediumTotal,
+ MediumYearlyTotal = mediumYearlyTotal,
+ LargeTotal = largeTotal,
+ LargeYearlyTotal = largeYearlyTotal,
MonthlyTotal = monthlyTotalPaid + (yearlyTotalPaid / 12),
YearlyTotal = (monthlyTotalPaid * 12) + yearlyTotalPaid,
- MonthlyTotalAccounts = smallOrganizations.Count + mediumOrganizations.Count + largeOrganizations.Count,
- YearlyTotalAccounts = smallYearlyOrganizations.Count + mediumYearlyOrganizations.Count + largeYearlyOrganizations.Count,
- FreeAccounts = results.Count(o => String.Equals(o.PlanId, _plans.FreePlan.Id)),
- PaidAccounts = results.Count(o => !String.Equals(o.PlanId, _plans.FreePlan.Id) && o.BillingPrice > 0),
- FreeloaderAccounts = results.Count(o => !String.Equals(o.PlanId, _plans.FreePlan.Id) && o.BillingPrice <= 0),
- SuspendedAccounts = results.Count(o => o.IsSuspended),
+ MonthlyTotalAccounts = smallTotal + mediumTotal + largeTotal,
+ YearlyTotalAccounts = smallYearlyTotal + mediumYearlyTotal + largeYearlyTotal,
+ FreeAccounts = freeAccounts,
+ TrialAccounts = trialAccounts,
+ ConvertedTrialAccounts = convertedTrialAccounts,
+ ExpiredTrialAccounts = expiredTrialAccounts,
+ PaidAccounts = paidAccounts,
+ FreeloaderAccounts = freeloaderAccounts,
+ SuspendedAccounts = suspendedAccounts,
};
}
}
diff --git a/src/Exceptionless.Core/Repositories/TrialClaimRepository.cs b/src/Exceptionless.Core/Repositories/TrialClaimRepository.cs
new file mode 100644
index 0000000000..d3f3967ccc
--- /dev/null
+++ b/src/Exceptionless.Core/Repositories/TrialClaimRepository.cs
@@ -0,0 +1,23 @@
+using Exceptionless.Core.Models;
+using Exceptionless.Core.Repositories.Configuration;
+using Exceptionless.Core.Validation;
+using Foundatio.Repositories;
+
+namespace Exceptionless.Core.Repositories;
+
+public sealed class TrialClaimRepository : RepositoryBase, ITrialClaimRepository
+{
+ public TrialClaimRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
+ : base(configuration.TrialClaims, validator, options)
+ {
+ // Eligibility reads use the document ID, which is real-time and needs no search refresh.
+ }
+
+ public Task RemoveCreatedBeforeAsync(DateTime utcCutoff)
+ {
+ if (utcCutoff.Kind is not DateTimeKind.Utc)
+ throw new ArgumentException("The trial claim cutoff must be UTC", nameof(utcCutoff));
+
+ return RemoveAllAsync(q => q.DateRange(null, utcCutoff, InferField(c => c.CreatedUtc)));
+ }
+}
diff --git a/src/Exceptionless.Core/Services/SystemSettingsService.cs b/src/Exceptionless.Core/Services/SystemSettingsService.cs
index 232b4a5e35..9df4ebdc6f 100644
--- a/src/Exceptionless.Core/Services/SystemSettingsService.cs
+++ b/src/Exceptionless.Core/Services/SystemSettingsService.cs
@@ -1,3 +1,4 @@
+using Exceptionless.Core.Billing;
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories;
using Foundatio.Lock;
@@ -93,6 +94,12 @@ public async Task IsEventSubmissionEnabledAsync()
return settings?.EventSubmissionEnabled ?? !_appOptions.EventSubmissionDisabled;
}
+ public async Task> GetTrialBlockedEmailDomainsAsync()
+ {
+ var settings = await _getSettingsAsync();
+ return settings?.TrialBlockedEmailDomains ?? TrialEmailAddress.DefaultBlockedDomains;
+ }
+
public async Task IsAssistantConversationSharingDefaultEnabledAsync()
{
var settings = await _getSettingsAsync();
diff --git a/src/Exceptionless.Core/Utility/SampleDataService.cs b/src/Exceptionless.Core/Utility/SampleDataService.cs
index 49b1d8feba..859540416c 100644
--- a/src/Exceptionless.Core/Utility/SampleDataService.cs
+++ b/src/Exceptionless.Core/Utility/SampleDataService.cs
@@ -121,7 +121,7 @@ public async Task CreateOrganizationAndProjectAsync(User user)
if (await _tokenRepository.ExistsAsync(TEST_API_KEY))
return;
- var organization = new Organization { Id = TEST_ORG_ID, Name = "Acme" };
+ var organization = new Organization { Id = TEST_ORG_ID, Name = "Acme", CreatedByUserId = user.Id };
_billingManager.ApplyBillingPlan(organization, _billingPlans.UnlimitedPlan, user);
organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache());
@@ -200,7 +200,7 @@ public async Task CreateFreeOrganizationAndProjectAsync()
if (await _tokenRepository.ExistsAsync(FREE_API_KEY))
return;
- var organization = new Organization { Id = FREE_ORG_ID, Name = "Free Plan Organization" };
+ var organization = new Organization { Id = FREE_ORG_ID, Name = "Free Plan Organization", CreatedByUserId = user.Id };
_billingManager.ApplyBillingPlan(organization, _billingPlans.FreePlan, user);
organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache());
@@ -249,7 +249,7 @@ public async Task CreateInternalOrganizationAndProjectAsync(string userId)
return;
}
- var organization = new Organization { Name = "Exceptionless" };
+ var organization = new Organization { Name = "Exceptionless", CreatedByUserId = user.Id };
_billingManager.ApplyBillingPlan(organization, _billingPlans.UnlimitedPlan, user);
organization = await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency().Cache());
diff --git a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs
index b0e97c481e..d0b51332ff 100644
--- a/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs
+++ b/src/Exceptionless.EmailTemplates/Models/EmailTemplate.cs
@@ -53,6 +53,13 @@ public sealed record OrganizationPaymentFailedEmail(
string BillingUrl)
: EmailTemplate(Subject, new EmailAction("Update Billing Information", BillingUrl));
+public sealed record OrganizationTrialEndingEmail(
+ string Subject,
+ string OrganizationName,
+ string TrialEndsDate,
+ string UpgradeUrl)
+ : EmailTemplate(Subject, new EmailAction("Upgrade Plan", UpgradeUrl));
+
public sealed record ProjectDailySummaryEmail(
string Subject,
string ProjectName,
diff --git a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs
index 17312bde78..c280fce7bb 100644
--- a/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs
+++ b/src/Exceptionless.EmailTemplates/RazorEmailTemplateRenderer.cs
@@ -34,6 +34,7 @@ public Task RenderAsync(EmailTemplate template, CancellationToken cancel
OrganizationInvitedEmail value => RenderComponentAsync(value),
OrganizationNoticeEmail value => RenderComponentAsync(value),
OrganizationPaymentFailedEmail value => RenderComponentAsync(value),
+ OrganizationTrialEndingEmail value => RenderComponentAsync(value),
ProjectDailySummaryEmail value => RenderComponentAsync(value),
UserEmailVerifyEmail value => RenderComponentAsync(value),
UserPasswordResetEmail value => RenderComponentAsync(value),
diff --git a/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor b/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor
new file mode 100644
index 0000000000..2de8ac35de
--- /dev/null
+++ b/src/Exceptionless.EmailTemplates/Templates/OrganizationTrialEnding.razor
@@ -0,0 +1,12 @@
+@namespace Exceptionless.EmailTemplates.Templates
+@inherits EmailTemplateComponent
+
+
+
+
Your trial ends soon
+
The free trial for "@Model.OrganizationName" ends on @Model.TrialEndsDate. Upgrade now to keep everything you've been using.
+
When the trial ends, @Model.OrganizationName moves to the Free plan: 3,000 events per month, 3 days of data retention, and no Exie, alerts, or integrations. Events older than 3 days will be deleted.