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.

+ Upgrade Plan +

Send us an email at support@exceptionless.io if you have any questions or need assistance.

+
+
diff --git a/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs b/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs index aa7f3df888..a543caa0eb 100644 --- a/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs +++ b/src/Exceptionless.Insulation/Security/SensitiveDataLogging.cs @@ -60,6 +60,7 @@ private sealed record SensitivePropertyRule( Redact(nameof(AuthOptions.MicrosoftSecret)), Redact(nameof(AuthOptions.GitHubSecret)), Redact(nameof(AuthOptions.GoogleSecret)), + Redact(nameof(BillingOptions.TrialClaimSecret)), Sanitize( nameof(AuthOptions.LdapConnectionString), static (_, value) => SanitizeStandaloneConnectionString((string?)value)), diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index 8cceab0727..ef848b138a 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -83,6 +83,10 @@ public JobRunnerOptions(string[] args) if (StackEventCount && args.Length != 0) JobName = nameof(StackEventCount); + TrialExpiration = args.Length is 0 || args.Contains(nameof(TrialExpiration), StringComparer.OrdinalIgnoreCase); + if (TrialExpiration && args.Length is not 0) + JobName = nameof(TrialExpiration); + WebHooks = args.Length == 0 || args.Contains(nameof(WebHooks), StringComparer.OrdinalIgnoreCase); if (WebHooks && args.Length != 0) JobName = nameof(WebHooks); @@ -112,6 +116,7 @@ public JobRunnerOptions(string[] args) public bool RunDataSeedStartupAction => !Migration; public bool StackStatus { get; } public bool StackEventCount { get; } + public bool TrialExpiration { get; } public bool WebHooks { get; } public bool WorkItem { get; } } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 0e65530af8..4bf440fda2 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -188,6 +188,11 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option services.AddJob(o => o.WaitForStartupActions()); if (options.StackEventCount) services.AddJob(o => o.WaitForStartupActions()); + if (options is { TrialExpiration: true, AllJobs: true }) + services.AddCronJob("15 * * * *"); + if (options is { TrialExpiration: true, AllJobs: false }) + services.AddJob(); + if (options.WebHooks) services.AddJob(o => o.WaitForStartupActions()); if (options.WorkItem) diff --git a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs index 5ebac61909..988f2ed9f8 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs @@ -1,5 +1,6 @@ using Exceptionless.Core; using Exceptionless.Core.Authorization; +using Exceptionless.Core.Billing; using Exceptionless.Core.Services; using Exceptionless.Web.Api.Filters; using Exceptionless.Web.Api.Messages; @@ -91,6 +92,25 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .WithTags(nameof(AdminEndpoints)) .WithSummary("Update event submission settings"); + endpoints.MapGet("api/v2/admin/trial-settings", GetTrialSettingsAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Get free trial settings"); + + endpoints.MapPut("api/v2/admin/trial-settings", UpdateTrialSettingsAsync) + .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) + .AddEndpointFilter() + .Accepts("application/json", "application/*+json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .WithTags(nameof(AdminEndpoints)) + .WithSummary("Update free trial settings"); + endpoints.MapGet("api/v2/admin/assistant-usage", GetAssistantUsageAsync) .RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy) .AddEndpointFilter() @@ -99,8 +119,8 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); - group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId) - => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext))).ToHttpResult(resultMapper)); + group.MapPost("change-plan", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, string planId, DateTimeOffset? trialEndsUtc = null) + => (await mediator.InvokeAsync>(new AdminChangePlan(organizationId, planId, httpContext, trialEndsUtc))).ToHttpResult(resultMapper)); group.MapPost("set-bonus", async (HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper, string organizationId, int bonusEvents, DateTime? expires = null) => (await mediator.InvokeAsync(new AdminSetBonus(organizationId, bonusEvents, expires, httpContext))).ToHttpResult(resultMapper)); @@ -153,6 +173,25 @@ private static async Task UpdateEventSubmissionSettingsAsync( return HttpResults.Ok(CreateEventSubmissionSettings(settings.EventSubmissionEnabled, appOptions)); } + private static async Task GetTrialSettingsAsync(SystemSettingsService settingsService) + { + var settings = await settingsService.GetAsync(); + return HttpResults.Ok(CreateTrialSettings(settings?.TrialBlockedEmailDomains)); + } + + private static async Task UpdateTrialSettingsAsync( + HttpContext httpContext, + [FromBody] UpdateTrialSettings request, + SystemSettingsService settingsService) + { + var blockedEmailDomains = request.BlockedEmailDomains is null ? null : TrialEmailAddress.NormalizeDomains(request.BlockedEmailDomains); + var settings = await settingsService.UpdateAsync(httpContext.Request.GetUser().Id, value => value.TrialBlockedEmailDomains = blockedEmailDomains); + return HttpResults.Ok(CreateTrialSettings(settings.TrialBlockedEmailDomains)); + } + + private static TrialSettings CreateTrialSettings(List? blockedEmailDomains) + => new(blockedEmailDomains ?? TrialEmailAddress.DefaultBlockedDomains, TrialEmailAddress.DefaultBlockedDomains, blockedEmailDomains is null); + private static EventSubmissionSettings CreateEventSubmissionSettings(bool? enabledOverride, AppOptions appOptions) { bool configuredEnabled = !appOptions.EventSubmissionDisabled; diff --git a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs index 5082053735..5c6d367fa5 100644 --- a/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs @@ -247,6 +247,9 @@ private static async Task SetConversationSharingAsync(UpdateAssistantCo AssistantAccessReason.UpgradeRequired => HttpResults.Problem( statusCode: StatusCodes.Status426UpgradeRequired, title: access.Message), + AssistantAccessReason.EmailVerificationRequired => HttpResults.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: access.Message), _ => HttpResults.NotFound() }; diff --git a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs index 5cd02efbe3..23059d0bba 100644 --- a/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/OrganizationEndpoints.cs @@ -280,8 +280,9 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute group.MapPost("organizations/{id:objectid}/users/{email:minlength(1)}", async (string id, string email, HttpContext httpContext, IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>(new OrganizationMessages.AddOrganizationUser(id, email, httpContext))).ToHttpResult(resultMapper)) .Produces() + .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound) - .ProducesProblem(StatusCodes.Status426UpgradeRequired) + .ProducesProblem(StatusCodes.Status429TooManyRequests) .WithSummary("Add user") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -289,8 +290,9 @@ public static IEndpointRouteBuilder MapOrganizationEndpoints(this IEndpointRoute ["email"] = "The email address of the user you wish to add to your organization.", }, ResponseDescriptions = new() { + ["403"] = "Please verify your email address before inviting users.", ["404"] = "The organization was not found.", - ["426"] = "Please upgrade your plan to add an additional user.", + ["429"] = "You've sent the maximum number of invites for today.", } }); diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index b1904356e7..ba414f8493 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -244,9 +244,12 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); - group.MapGet("users/verify-email-address/{token:token}", async (string token, IMediator mediator, IMediatorResultMapper resultMapper) - => (await mediator.InvokeAsync(new UserMessages.VerifyEmailAddress(token))).ToHttpResult(resultMapper)) - .Produces(StatusCodes.Status200OK) + group.MapGet("users/verify-email-address/{token:token}", async (string token, IMediator mediator, IMediatorResultMapper resultMapper) => + { + var result = await mediator.InvokeAsync>(new UserMessages.VerifyEmailAddress(token)); + return result.ToHttpResult(resultMapper); + }) + .Produces() .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status422UnprocessableEntity) .WithSummary("Verify email address") diff --git a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs index c758517674..016f9f71f4 100644 --- a/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/AdminHandler.cs @@ -266,6 +266,8 @@ public async Task> Handle(AdminChangePlan message) if (String.IsNullOrEmpty(message.OrganizationId) || !httpContext.Request.CanAccessOrganization(message.OrganizationId)) return new ChangePlanResponse(false, "Invalid Organization Id."); + // Share the billing lock with user plan changes and the trial expiration job, and reload inside it. + await using var billingLock = await billingManager.AcquireOrganizationLockAsync(message.OrganizationId); var organization = await organizationRepository.GetByIdAsync(message.OrganizationId); if (organization is null) return new ChangePlanResponse(false, "Invalid Organization Id."); @@ -274,12 +276,26 @@ public async Task> Handle(AdminChangePlan message) if (plan is null) return new ChangePlanResponse(false, "Invalid PlanId."); - organization.BillingStatus = !String.Equals(plan.Id, plans.FreePlan.Id) ? BillingStatus.Active : BillingStatus.Trialing; + if (message.TrialEndsUtc is not null && !plans.IsTrialPlan(plan.Id)) + return new ChangePlanResponse(false, "A trial end date can only be set for a trial plan."); + + // Trials start or extend a no-card trial. A paying organization keeps its subscription, so don't move it onto one. + if (plans.IsTrialPlan(plan.Id) && !plans.IsFreeOrTrialPlan(organization.PlanId)) + return new ChangePlanResponse(false, "Only free or trial organizations can be put on a trial."); + + organization.BillingStatus = !plans.IsFreeOrTrialPlan(plan.Id) ? BillingStatus.Active : BillingStatus.Trialing; organization.RemoveSuspension(); var currentUser = httpContext.Request.GetUser(); - billingManager.ApplyBillingPlan(organization, plan, currentUser, false); + if (plans.IsTrialPlan(plan.Id)) + { + billingManager.ApplyTrialPlan(organization, currentUser); + if (message.TrialEndsUtc is not null) + organization.TrialEndsUtc = message.TrialEndsUtc.Value.UtcDateTime; + } + else + billingManager.ApplyBillingPlan(organization, plan, currentUser, false); - await organizationRepository.SaveAsync(organization, o => o.Cache().Originals()); + await organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); await messagePublisher.PublishAsync(new PlanChanged { OrganizationId = organization.Id diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 574c7cb899..833932a5a8 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -17,6 +17,7 @@ using Exceptionless.Web.Models; using Exceptionless.Web.Utility; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Mediator; using Foundatio.Messaging; using Foundatio.Repositories; @@ -110,11 +111,23 @@ public async Task> Handle(CreateOrganization message) return Result.BadRequest("Organization value is required."); var model = mapper.MapToOrganization(message.Organization); - var error = await CanAddAsync(model, message.Context); + + // Serialize creation per user and reload the user so concurrent requests can't both pass the + // one-free-organization and one-trial checks. + string userId = GetCurrentUser(message.Context).Id; + await using var ownerLock = await billingManager.AcquireOwnerFreeOrganizationLockAsync(userId); + var user = await userRepository.GetByIdAsync(userId); + if (user is null) + return Result.Unauthorized("User not found."); + + // Aliases of one mailbox are different users, so the trial claim is serialized on the mailbox too. + await using var trialEmailLock = await billingManager.AcquireTrialEmailLockAsync(user); + + var error = await CanAddAsync(model, user); if (error is not null) return error; - model = await AddModelAsync(model, message.Context); + model = await AddModelAsync(model, user); var viewModel = mapper.MapToViewOrganization(model); await AfterResultMapAsync([viewModel]); string location = linkGenerator.GetUriByName(message.Context, "GetOrganizationById", new { id = model.Id }) @@ -387,6 +400,13 @@ public async Task> Handle(ChangeOrganizationPlan messag if (String.Equals(organization.PlanId, plan.Id) && String.Equals(plans.FreePlan.Id, plan.Id)) return ChangePlanResult.SuccessWithMessage("Your plan was not changed as you were already on the free plan."); + // Downgrades to Free share the owner's lock with organization creation so two can't both pass the one-free-organization rule. + bool acquireOwnerLock = plans.IsFreePlan(plan.Id) && !String.Equals(organization.PlanId, plan.Id); + ILock? ownerLock = null; + if (acquireOwnerLock) + ownerLock = await billingManager.AcquireOwnerFreeOrganizationLockAsync(organization.CreatedByUserId ?? GetCurrentUser(message.Context).Id); + await using var ownerLockScope = ownerLock; + if (!String.Equals(organization.PlanId, plan.Id)) { var result = await billingManager.CanDownGradeAsync(organization, plan, GetCurrentUser(message.Context)); @@ -629,11 +649,17 @@ public async Task> Handle(AddOrganizationUser message) if (String.IsNullOrEmpty(message.Id) || !message.Context.Request.CanAccessOrganization(message.Id) || String.IsNullOrEmpty(message.Email)) return Result.NotFound("Organization not found."); - var organization = await GetModelAsync(message.Id); + await using var billingLock = await billingManager.AcquireOrganizationLockAsync(message.Id); + var organization = await GetModelAsync(message.Id, useCache: false); if (organization is null) return Result.NotFound("Organization not found."); - if (!await billingManager.CanAddUserAsync(organization)) + var inviteLimitResult = await CheckInviteLimitAsync(organization, message.Context); + if (inviteLimitResult is not null) + return inviteLimitResult; + + bool canAddUser = await billingManager.CanAddUserAsync(organization); + if (!canAddUser) return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add an additional user.")); var user = await userRepository.GetByEmailAddressAsync(message.Email); @@ -642,7 +668,8 @@ public async Task> Handle(AddOrganizationUser message) if (!user.OrganizationIds.Contains(organization.Id)) { user.OrganizationIds.Add(organization.Id); - await userRepository.SaveAsync(user, o => o.Cache()); + // Finite user limits count memberships through search before the next admission. + await userRepository.SaveAsync(user, o => o.Cache().ImmediateConsistency()); await messagePublisher.PublishAsync(new UserMembershipChanged { ChangeType = ChangeType.Added, @@ -674,12 +701,36 @@ await messagePublisher.PublishAsync(new UserMembershipChanged return new User { EmailAddress = message.Email }; } + private async Task?> CheckInviteLimitAsync(Organization organization, HttpContext httpContext) + { + // Paid organizations keep inviting as before. Free and trial organizations are where throwaway signups would + // send invite spam, so only they need a verified inviter and a daily limit. + if (!options.StripeOptions.EnableBilling || httpContext.Request.IsGlobalAdmin() || !plans.IsFreeOrTrialPlan(organization.PlanId)) + return null; + + var currentUser = GetCurrentUser(httpContext); + if (!currentUser.IsEmailAddressVerified) + return Result.Forbidden("Please verify your email address before inviting users."); + + bool withinInviteLimit = await billingManager.TryCountFreeInviteAsync(currentUser.Id); + if (withinInviteLimit) + return null; + + _logger.LogWarning("User {UserId} reached the daily invite limit of {DailyLimit} for organization {OrganizationId}", currentUser.Id, options.BillingOptions.FreeInviteDailyLimit, organization.Id); + return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.RateLimit, "You've sent the maximum number of invites for today. Please try again tomorrow or contact support.")); + } + public async Task Handle(RemoveOrganizationUser message) { + if (String.IsNullOrEmpty(message.Id) || !message.Context.Request.CanAccessOrganization(message.Id)) + return Result.NotFound("Organization not found."); + + await using var billingLock = await billingManager.AcquireOrganizationLockAsync(message.Id); var organization = await GetModelAsync(message.Id, useCache: false); if (organization is null) return Result.NotFound("Organization not found."); + bool inviteRemoved = false; var user = await userRepository.GetByEmailAddressAsync(message.Email); if (user is null || !user.OrganizationIds.Contains(message.Id)) { @@ -688,7 +739,7 @@ public async Task Handle(RemoveOrganizationUser message) return Result.Success(); organization.Invites.Remove(invite); - await repository.SaveAsync(organization, o => o.Cache()); + inviteRemoved = true; } else { @@ -707,7 +758,8 @@ public async Task Handle(RemoveOrganizationUser message) user.OrganizationPreferences.Remove(preference); foreach (var preference in user.SavedViewOrders.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList()) user.SavedViewOrders.Remove(preference); - await userRepository.SaveAsync(user, o => o.Cache()); + // The following plan-limit check counts memberships through search. + await userRepository.SaveAsync(user, o => o.Cache().ImmediateConsistency()); await messagePublisher.PublishAsync(new UserMembershipChanged { ChangeType = ChangeType.Removed, @@ -716,6 +768,9 @@ await messagePublisher.PublishAsync(new UserMembershipChanged }); } + bool suspensionChanged = await billingManager.UpdatePlanLimitSuspensionAsync(organization); + if (inviteRemoved || suspensionChanged) + await repository.SaveAsync(organization, command => command.Cache().Originals()); return Result.Success(); } @@ -817,29 +872,52 @@ private async Task MapToViewAsync(Organization model) return viewModel; } - private async Task?> CanAddAsync(Organization value, HttpContext httpContext) + private async Task?> CanAddAsync(Organization value, User user) { if (String.IsNullOrEmpty(value.Name)) return Result.BadRequest("Organization name is required."); - if (!await billingManager.CanAddOrganizationAsync(GetCurrentUser(httpContext))) + bool canAddOrganization = await billingManager.CanAddOrganizationAsync(user); + if (!canAddOrganization) return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add an additional organization.")); return null; } - private async Task AddModelAsync(Organization value, HttpContext httpContext) + private async Task AddModelAsync(Organization value, User user) { - var user = GetCurrentUser(httpContext); - var plan = !options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin) - ? plans.UnlimitedPlan - : plans.FreePlan; - billingManager.ApplyBillingPlan(value, plan, user); + value.CreatedByUserId = user.Id; + if (!options.StripeOptions.EnableBilling || user.Roles.Contains(AuthorizationRoles.GlobalAdmin)) + { + billingManager.ApplyBillingPlan(value, plans.UnlimitedPlan, user); + } + else + { + bool startTrial = await billingManager.CanStartTrialAsync(user); + if (startTrial) + startTrial = await billingManager.TryClaimTrialAsync(user); - var organization = await repository.AddAsync(value, o => o.Cache()); + if (startTrial) + billingManager.StartTrial(value, user); + else + billingManager.ApplyBillingPlan(value, plans.FreePlan, user); + } + + var organization = await repository.AddAsync(value, o => o.ImmediateConsistency().Cache()); user.OrganizationIds.Add(organization.Id); - await userRepository.SaveAsync(user, o => o.Cache()); + try + { + await userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + } + catch (Exception ex) + { + // Without the membership the user couldn't reach the organization, but it would still count as theirs. + _logger.LogError(ex, "Unable to add user {UserId} to new organization {OrganizationId}, removing it: {Message}", user.Id, organization.Id, ex.Message); + await repository.RemoveAsync(organization, o => o.ImmediateConsistency()); + throw; + } + await messagePublisher.PublishAsync(new UserMembershipChanged { UserId = user.Id, diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index 3705100472..1d9d84d45c 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -28,12 +28,12 @@ namespace Exceptionless.Web.Api.Handlers; public class ProjectHandler( IOrganizationRepository organizationRepository, + BillingManager billingManager, IProjectRepository repository, IStackRepository stackRepository, IEventRepository eventRepository, ITokenRepository tokenRepository, IQueue workItemQueue, - BillingManager billingManager, SlackService slackService, IOAuthProviderClient oauthProviderClient, SampleDataService sampleDataService, @@ -109,6 +109,11 @@ public async Task> Handle(CreateProject message) if (String.IsNullOrEmpty(model.OrganizationId) && message.Context.Request.GetAssociatedOrganizationIds().Count > 0) model.OrganizationId = message.Context.Request.GetDefaultOrganizationId()!; + if (String.IsNullOrWhiteSpace(model.OrganizationId) || !message.Context.Request.CanAccessOrganization(model.OrganizationId)) + return Result.BadRequest("Invalid organization id specified."); + + await using var billingLock = await billingManager.AcquireOrganizationLockAsync(model.OrganizationId); + var error = await CanAddAsync(model, message.Context); if (error is not null) return error; @@ -544,7 +549,8 @@ private async Task AfterResultMapAsync(ICollection m if (!await IsProjectNameAvailableInternalAsync(value.OrganizationId, value.Name, null, httpContext)) return Result.BadRequest("A project with this name already exists."); - if (!await billingManager.CanAddProjectAsync(value)) + bool canAddProject = await billingManager.CanAddProjectAsync(value); + if (!canAddProject) return Result.Invalid(ValidationError.Create(ApiValidationErrorIdentifiers.PlanLimit, "Please upgrade your plan to add additional projects.")); return null; @@ -558,7 +564,8 @@ private Task AddModelAsync(Project value, HttpContext httpContext) value.AddDefaultNotificationSettings(GetCurrentUserId(httpContext)); value.SetDefaultUserAgentBotPatterns(); value.Configuration.IncrementVersion(); - return repository.AddAsync(value, o => o.Cache()); + // The next capacity check uses a search count, so creation must be visible before releasing the billing lock. + return repository.AddAsync(value, o => o.Cache().ImmediateConsistency()); } private async Task?> CanUpdateAsync(Project original, Delta changes, HttpContext httpContext) @@ -597,7 +604,10 @@ private async Task> DeleteModelsAsync(ICollection p foreach (var project in projects.OfType()) project.IsDeleted = true; - await repository.SaveAsync(projects); + // A suspended organization needs the updated project count before checking whether ingestion can resume. + await repository.SaveAsync(projects, command => command.ImmediateConsistency()); + foreach (string organizationId in projects.Select(project => project.OrganizationId).Distinct(StringComparer.Ordinal)) + await billingManager.RecheckPlanLimitSuspensionAsync(organizationId); return []; } diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 9a8a6e9787..2ef9c8804c 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -306,14 +306,14 @@ public async Task> Handle(UpdateEmailAddress me return new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified }; } - public async Task Handle(VerifyEmailAddress message) + public async Task> Handle(VerifyEmailAddress message) { var user = await repository.GetByVerifyEmailAddressTokenAsync(message.Token); if (user is null) { var currentUser = HttpContext.Request.GetUser(); if (currentUser.IsEmailAddressVerified) - return Result.Success(); + return new VerifyEmailAddressResult { EmailAddress = currentUser.EmailAddress }; return Result.NotFound("User not found."); } @@ -324,7 +324,7 @@ public async Task Handle(VerifyEmailAddress message) user.MarkEmailAddressVerified(); await repository.SaveAsync(user, o => o.Cache()); - return Result.Success(); + return new VerifyEmailAddressResult { EmailAddress = user.EmailAddress }; } public async Task Handle(ResendVerificationEmail message) diff --git a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs index 686e527af1..5c7be4cb8b 100644 --- a/src/Exceptionless.Web/Api/Messages/AdminMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/AdminMessages.cs @@ -8,7 +8,7 @@ public record AdminRerunMigration(int Version, string? Confirmation, HttpContext public record GetAdminMigrationRerun(string OperationId); public record GetAdminEcho(HttpContext Context); public record GetAdminAssemblies; -public record AdminChangePlan(string OrganizationId, string PlanId, HttpContext Context); +public record AdminChangePlan(string OrganizationId, string PlanId, HttpContext Context, DateTimeOffset? TrialEndsUtc = null); public record AdminSetBonus(string OrganizationId, int BonusEvents, DateTime? Expires, HttpContext Context); public record AdminRequeue(string? Path = null, bool Archive = false); public record AdminRunMaintenance(string Name, DateTime? UtcStart, DateTime? UtcEnd, string? OrganizationId); diff --git a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs index 576312cf28..36f63a7864 100644 --- a/src/Exceptionless.Web/Assistant/AssistantAccessService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantAccessService.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; +using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; using Foundatio.Repositories; using Foundatio.Repositories.Options; @@ -13,7 +14,8 @@ public sealed class AssistantAccessService( AppOptions appOptions, BillingPlans billingPlans, IOrganizationRepository organizationRepository, - SystemSettingsService systemSettingsService) + SystemSettingsService systemSettingsService, + TimeProvider timeProvider) { public async Task GetAccessAsync(HttpRequest request, string? organizationId) { @@ -41,7 +43,18 @@ public async Task GetAccessAsync(HttpRequest request, s if (appOptions.AppMode == AppMode.Development) return AssistantAccessDecision.Available(billingPlans.UnlimitedPlan.Assistant!); - return EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); + if (!billingPlans.IsTrialPlan(organization.PlanId)) + return EvaluatePlan(billingPlans.GetPlan(organization.PlanId)?.Assistant, billingPlans.MediumPlan.Id); + + // Don't rely on the expiration job having run: an ended trial no longer includes Exie. + var utcNow = timeProvider.GetUtcNow().UtcDateTime; + if (organization.TrialEndsUtc is null || organization.TrialEndsUtc.Value.IsBeforeOrEqual(utcNow)) + return EvaluatePlan(planOptions: null, billingPlans.MediumPlan.Id); + + if (!request.GetUser().IsEmailAddressVerified) + return AssistantAccessDecision.EmailVerificationRequired("Verify your email address to use Exie during your trial."); + + return EvaluatePlan(billingPlans.TrialPlan.Assistant, billingPlans.MediumPlan.Id); } internal static AssistantAccessDecision? EvaluateConfiguration(AppOptions appOptions, bool? enabled = null) @@ -71,7 +84,8 @@ public enum AssistantAccessReason NotConfigured, OrganizationRequired, OrganizationNotAccessible, - UpgradeRequired + UpgradeRequired, + EmailVerificationRequired } public sealed record AssistantAccessDecision( @@ -85,6 +99,8 @@ public sealed record AssistantAccessDecision( { public static AssistantAccessDecision Available(AssistantPlanOptions? planOptions) => new(true, true, false, AssistantAccessReason.Available, null, planOptions, null); + public static AssistantAccessDecision EmailVerificationRequired(string message) => new(true, false, false, AssistantAccessReason.EmailVerificationRequired, message, null, null); + public static AssistantAccessDecision Unavailable( AssistantAccessReason reason, string message, @@ -92,5 +108,5 @@ public static AssistantAccessDecision Unavailable( bool upgradeRequired = false, string? minimumPlanId = null) => new(enabled, false, upgradeRequired, reason, message, null, minimumPlanId); - public AssistantAccessResponse ToResponse() => new(Enabled, HasAccess, UpgradeRequired, Message, MinimumPlanId); + public AssistantAccessResponse ToResponse() => new(Enabled, HasAccess, UpgradeRequired, Message, MinimumPlanId, Reason is AssistantAccessReason.EmailVerificationRequired); } diff --git a/src/Exceptionless.Web/Assistant/AssistantModels.cs b/src/Exceptionless.Web/Assistant/AssistantModels.cs index 222631ae90..61068200d0 100644 --- a/src/Exceptionless.Web/Assistant/AssistantModels.cs +++ b/src/Exceptionless.Web/Assistant/AssistantModels.cs @@ -34,7 +34,8 @@ public sealed record AssistantAccessResponse( bool HasAccess, bool UpgradeRequired, string? Message = null, - string? MinimumPlanId = null); + string? MinimumPlanId = null, + bool EmailVerificationRequired = false); public sealed record AssistantSuggestedAction(string Label, string Prompt, string? Href = null); diff --git a/src/Exceptionless.Web/Assistant/AssistantService.cs b/src/Exceptionless.Web/Assistant/AssistantService.cs index df1ad68c4a..0f49d98234 100644 --- a/src/Exceptionless.Web/Assistant/AssistantService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantService.cs @@ -130,7 +130,7 @@ internal async IAsyncEnumerable StreamAsync( if (diagnostics is not null) diagnostics.Stage = "usage_reservation"; - await using var providerRequest = await assistantUsageService.StartProviderRequestAsync(request.OrganizationId, providerInputCharacters); + await using var providerRequest = await assistantUsageService.StartProviderRequestAsync(request.OrganizationId, providerInputCharacters, planOptions); using var providerDiagnostics = diagnostics?.StartProviderRequest(cancellationToken); bool receivedDone = false; bool receivedFinishReason = false; diff --git a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs index 57ef27a40a..1cd144a961 100644 --- a/src/Exceptionless.Web/Assistant/AssistantUsageService.cs +++ b/src/Exceptionless.Web/Assistant/AssistantUsageService.cs @@ -60,6 +60,13 @@ public async Task TryStartTurnAsync(string? organizati "This organization has reached Exie's monthly AI cost limit.")); } + var sharedDailyCostDecision = await GetSharedDailyCostDecisionAsync(planOptions, now); + if (sharedDailyCostDecision is not null) + { + await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByCostLimit = 1 }); + return AssistantTurnReservation.Blocked(sharedDailyCostDecision); + } + if (monthlyUsage.TotalTokens >= planOptions.MaximumMonthlyTokens) { await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByTokenLimit = 1 }); @@ -129,6 +136,13 @@ public async Task TryContinueTurnAsync(string? organizat "This organization reached Exie's monthly AI cost limit while completing the response."); } + var sharedDailyCostDecision = await GetSharedDailyCostDecisionAsync(planOptions, now); + if (sharedDailyCostDecision is not null) + { + await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByCostLimit = 1 }); + return sharedDailyCostDecision; + } + if (monthlyUsage.TotalTokens >= planOptions.MaximumMonthlyTokens) { await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByTokenLimit = 1 }); @@ -169,9 +183,37 @@ await Task.WhenAll( }; } - public async Task StartProviderRequestAsync(string? organizationId, int providerInputCharacters = 0) + public async Task StartProviderRequestAsync(string? organizationId, int providerInputCharacters = 0, AssistantPlanOptions? planOptions = null) { var reservation = await RecordProviderRequestStartedAsync(organizationId, providerInputCharacters); + if (planOptions is { SharedDailyCostLimitKey: { } sharedDailyCostLimitKey, MaximumSharedDailyCostUsd: > 0 } && reservation.CostInMicrodollars is > 0) + { + // Count the estimate immediately so concurrent turns can't all pass the shared limit before any cost lands. + var sharedReservation = reservation with { SharedDailyCostLimitKey = sharedDailyCostLimitKey }; + long? sharedDailyCost = await IncrementSharedDailyCostAsync(sharedReservation, sharedReservation.CostInMicrodollars); + + if (sharedDailyCost is null) + { + // The shared increment may not have been applied, so only release the monthly reservation. + await ReleaseProviderReservationAsync(organizationId, reservation); + throw new AssistantProviderException("Exie is temporarily unavailable. Try again in a moment.") { FailureCode = "usage_unavailable" }; + } + + reservation = sharedReservation; + + // Increments are atomic, so only the request that crosses the limit sees a total below it before its own + // estimate. Every later request is rejected, which keeps a burst within one estimate of the limit. + if (sharedDailyCost - sharedReservation.CostInMicrodollars >= ToMicrodollars(planOptions.MaximumSharedDailyCostUsd)) + { + await ReleaseProviderReservationAsync(organizationId, reservation); + if (!String.IsNullOrWhiteSpace(organizationId)) + await TryRecordUsageAsync(organizationId, new AssistantUsageIncrement { BlockedByCostLimit = 1 }); + + logger.LogWarning("Assistant shared daily cost limit reached for {SharedDailyCostLimitKey}", sharedDailyCostLimitKey); + throw new AssistantProviderException(SharedDailyCostLimitMessage) { FailureCode = "usage_limit" }; + } + } + return new AssistantProviderUsageLifecycle(this, organizationId, reservation); } @@ -220,9 +262,7 @@ internal async Task ReleaseProviderReservationAsync( if (String.IsNullOrWhiteSpace(organizationId) || !reservation.HasValue) return; - var usageDate = reservation.UsageDateUtc is { } date - ? new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)) - : timeProvider.GetUtcNow(); + var usageDate = GetUsageDate(reservation); var month = reservation is { MonthId: not null, ExpiresAtUtc: not null } ? new UsageWindow(reservation.MonthId, reservation.ExpiresAtUtc.Value) : GetMonthWindow(usageDate); @@ -235,6 +275,8 @@ await UpdateProviderUsageCacheWithInitializationAsync( costInMicrodollars: 0, reservation, reverse: false); + + await IncrementSharedDailyCostAsync(reservation, -reservation.CostInMicrodollars); } private async Task RecordProviderUsageAsync( @@ -250,9 +292,7 @@ private async Task RecordProviderUsageAsync( if (reservation is { MonthId: not null, ExpiresAtUtc: not null }) { month = new UsageWindow(reservation.MonthId, reservation.ExpiresAtUtc.Value); - usageDate = reservation.UsageDateUtc is { } date - ? new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)) - : timeProvider.GetUtcNow(); + usageDate = GetUsageDate(reservation); } else { @@ -293,8 +333,60 @@ await UpdateProviderUsageCacheWithInitializationAsync( reverse: true); throw; } + + if (reservation is not null) + await IncrementSharedDailyCostAsync(reservation, costInMicrodollars - reservation.CostInMicrodollars); } + /// + /// Adjusts the shared daily cost counter for a reservation's day. The estimate is added when the provider + /// request starts, then replaced by the actual cost or removed when the reservation is released. + /// + /// The new shared daily cost, or null when the adjustment was skipped or could not be confirmed. + private async Task IncrementSharedDailyCostAsync(AssistantProviderReservation reservation, long costInMicrodollars) + { + if (reservation.SharedDailyCostLimitKey is not { } sharedDailyCostLimitKey || costInMicrodollars is 0) + return null; + + var usageDate = GetUsageDate(reservation); + var day = GetDayWindow(usageDate); + + // Reconciliation runs after durable usage is recorded, so a failure here must not cause that usage to be + // recorded again. Return null so admission can reject a request whose reservation could not be confirmed. + try + { + long total = await _cache.IncrementAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id), costInMicrodollars, day.ResetAtUtc.UtcDateTime); + return total; + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to update the shared daily assistant cost for {SharedDailyCostLimitKey}: {Message}", sharedDailyCostLimitKey, ex.Message); + return null; + } + } + + private const string SharedDailyCostLimitMessage = "Exie is at capacity for trial organizations right now. Upgrade your plan for guaranteed access."; + + private async Task GetSharedDailyCostDecisionAsync(AssistantPlanOptions planOptions, DateTimeOffset utcNow) + { + if (planOptions is not { SharedDailyCostLimitKey: { } sharedDailyCostLimitKey, MaximumSharedDailyCostUsd: > 0 }) + return null; + + var day = GetDayWindow(utcNow); + var cost = await _cache.GetAsync(GetSharedDailyCostKey(sharedDailyCostLimitKey, day.Id)); + if (!cost.HasValue || cost.Value < ToMicrodollars(planOptions.MaximumSharedDailyCostUsd)) + return null; + + logger.LogWarning("Assistant shared daily cost limit reached for {SharedDailyCostLimitKey}", sharedDailyCostLimitKey); + return AssistantUsageDecision.Blocked( + AssistantUsageLimit.SharedDailyCost, + day.ResetAtUtc, + SharedDailyCostLimitMessage); + } + + private static string GetSharedDailyCostKey(string sharedDailyCostLimitKey, string dayId) + => $"shared:{sharedDailyCostLimitKey}:usage:{dayId}:cost-microdollars"; + private async Task UpdateProviderUsageCacheWithInitializationAsync( string organizationId, DateTimeOffset usageDate, @@ -477,6 +569,19 @@ private static UsageWindow GetMinuteWindow(DateTimeOffset now) return new UsageWindow(id.ToString(), DateTimeOffset.FromUnixTimeSeconds((id + 1) * 60)); } + /// + /// When the reserved provider request started, so its cost lands in that day and month even if it finishes after + /// midnight. + /// + private DateTimeOffset GetUsageDate(AssistantProviderReservation reservation) + => reservation.UsageDateUtc is { } usageDateUtc ? new DateTimeOffset(usageDateUtc.Ticks, TimeSpan.Zero) : timeProvider.GetUtcNow(); + + private static UsageWindow GetDayWindow(DateTimeOffset utcNow) + { + var utcStartDate = utcNow.UtcDateTime.Date; + return new UsageWindow(utcStartDate.ToString("yyyyMMdd"), utcStartDate.AddDays(1)); + } + private static UsageWindow GetMonthWindow(DateTimeOffset now) { var start = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); @@ -511,7 +616,8 @@ public enum AssistantUsageLimit ConcurrentTurns, MinuteTurns, MonthlyTokens, - MonthlyCost + MonthlyCost, + SharedDailyCost } public sealed record AssistantUsageDecision( @@ -535,6 +641,7 @@ public sealed record AssistantProviderReservation(long PromptTokens, long Comple internal string? MonthId { get; init; } internal DateTimeOffset? ExpiresAtUtc { get; init; } internal DateTime? UsageDateUtc { get; init; } + internal string? SharedDailyCostLimitKey { get; init; } } public sealed class AssistantProviderUsageLifecycle : IAsyncDisposable diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index f70a8d49b4..aaa90845cb 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -5,6 +5,7 @@ import type { E2EEnvironment } from './environment'; export interface E2ECurrentUser { email_address?: string; id: string; + is_email_address_verified?: boolean; product_tours?: Record; } @@ -262,7 +263,7 @@ export class E2EApiClient { throw new Error(`Timed out waiting for E2E event with reference id ${referenceId}`); } - public async pollForMailLink(email: string, path: 'reset-password' | 'signup', timeoutMs = 30_000): Promise { + public async pollForMailLink(email: string, path: 'reset-password' | 'signup' | 'verify-email', timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; const normalizedEmail = email.toLowerCase(); @@ -435,13 +436,19 @@ async function expectStatus(response: APIResponse, expectedStatuses: number[], o throw new Error(`${operation} failed with status ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`); } -function extractMailLink(content: string, path: 'reset-password' | 'signup'): string | undefined { +function extractMailLink(content: string, path: 'reset-password' | 'signup' | 'verify-email'): string | undefined { const links = content.replaceAll('&', '&').match(/https?:\/\/[^"'<\\\s]+/g) ?? []; return links.find((link) => { const url = new URL(link); - return path === 'signup' - ? url.pathname === '/signup' && url.searchParams.has('token') - : url.pathname.startsWith('/reset-password/') && !url.searchParams.has('cancel'); + if (path === 'signup') { + return url.pathname === '/signup' && url.searchParams.has('token'); + } + + if (path === 'verify-email') { + return url.pathname === '/account/verify' && url.searchParams.has('token'); + } + + return url.pathname.startsWith('/reset-password/') && !url.searchParams.has('cancel'); }); } @@ -480,6 +487,7 @@ function toCurrentUser(value: unknown): E2ECurrentUser { return { email_address: getOptionalString(record, 'email_address'), id: getRequiredString(record, 'id', 'current user response'), + is_email_address_verified: typeof record.is_email_address_verified === 'boolean' ? record.is_email_address_verified : undefined, product_tours: record.product_tours == null ? undefined : toRecord(record.product_tours, 'current user product tours') }; } diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/email-verification.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/email-verification.e2e.ts new file mode 100644 index 0000000000..22adbfd8e9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/email-verification.e2e.ts @@ -0,0 +1,71 @@ +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; + +test.skip(process.env.E2E_ENV === 'production', 'Email verification requires local Mailpit.'); +test.use({ e2eInjectBrowserToken: false, e2eUseInvitedUser: true }); + +test('an invalid verification link preserves the unverified account @signup', async ({ e2eApi, e2eScenario, page }) => { + // Arrange + const invalidLink = '/account/verify?token=invalidverificationtoken1234567890'; + + // Act + await page.goto(invalidLink); + await page.waitForURL(/\/login\?redirect=/); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + await page.waitForURL(/\/account\/manage(?:[?#]|$)/); + const user = await e2eApi.getCurrentUser(e2eScenario.userToken); + + // Assert + expect(user?.is_email_address_verified).toBe(false); + await expect(page.getByText('An error occurred while verifying your account.')).toBeVisible(); +}); + +test('verification in another tab refreshes the original account page @signup', async ({ e2eApi, e2eScenario, page }) => { + // Arrange + const verificationLink = await e2eApi.pollForMailLink(e2eScenario.email, 'verify-email'); + const initialUser = await e2eApi.getCurrentUser(e2eScenario.userToken); + + // Act + await page.goto('/login?redirect=%2Faccount%2Fmanage'); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + await page.waitForURL(/\/account\/manage(?:[?#]|$)/); + const confirmationPage = await page.context().newPage(); + await confirmationPage.goto(verificationLink); + await confirmationPage.waitForURL(/\/account\/manage(?:[?#]|$)/); + await confirmationPage.getByText('Your account has been successfully verified.').waitFor(); + const verifiedUser = await e2eApi.getCurrentUser(e2eScenario.userToken); + await confirmationPage.reload(); + await page.bringToFront(); + + // Assert + expect(initialUser?.is_email_address_verified).toBe(false); + expect(verifiedUser?.is_email_address_verified).toBe(true); + await expect(confirmationPage.getByLabel('Email', { exact: true })).toHaveValue(e2eScenario.email); + await expect(confirmationPage.getByText('Email not verified.')).toHaveCount(0); + await expect(page.getByText('Email not verified.')).toHaveCount(0); +}); + +test('a legacy verification link survives the login redirect @signup', async ({ e2eApi, e2eScenario, page }) => { + // Arrange + const deliveredLink = await e2eApi.pollForMailLink(e2eScenario.email, 'verify-email'); + const verificationLink = new URL(deliveredLink); + verificationLink.hash = `!${verificationLink.pathname}${verificationLink.search}`; + verificationLink.pathname = '/'; + verificationLink.search = ''; + + // Act + await page.goto(verificationLink.toString()); + await page.waitForURL(/\/login\?redirect=/); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + await page.waitForURL(/\/account\/manage(?:[?#]|$)/); + const user = await e2eApi.getCurrentUser(e2eScenario.userToken); + + // Assert + expect(user?.is_email_address_verified).toBe(true); + await expect(page.getByText('Your account has been successfully verified.')).toBeVisible(); +}); diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 2b934ad639..055a119b4b 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -582,7 +582,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -599,7 +598,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -616,7 +614,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -633,7 +630,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -650,7 +646,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -667,7 +662,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -684,7 +678,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -701,7 +694,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -718,7 +710,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -735,7 +726,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -752,7 +742,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -769,7 +758,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -786,7 +774,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -803,7 +790,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -820,7 +806,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -837,7 +822,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -854,7 +838,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -871,7 +854,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -888,7 +870,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -905,7 +886,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -922,7 +902,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -939,7 +918,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -956,7 +934,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -973,7 +950,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -990,7 +966,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1007,7 +982,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2242,7 +2216,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2259,7 +2232,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2276,7 +2248,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2293,7 +2264,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2310,7 +2280,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2327,7 +2296,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2344,7 +2312,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2361,7 +2328,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2378,7 +2344,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2395,7 +2360,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2412,7 +2376,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2429,7 +2392,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2446,7 +2408,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2463,7 +2424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2480,7 +2440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4351,7 +4310,7 @@ "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -6263,7 +6222,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7381,7 +7340,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -11779,7 +11738,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11827,7 +11786,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unified": { @@ -12135,7 +12094,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12183,7 +12141,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12204,7 +12161,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12225,7 +12181,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12246,7 +12201,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12267,7 +12221,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12288,7 +12241,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12309,7 +12261,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12330,7 +12281,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12351,7 +12301,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12372,7 +12321,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12393,7 +12341,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12767,7 +12714,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index 3e1b05d92a..4189ad7edc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -1,15 +1,17 @@ import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; -import type { WorkInProgressResult } from '#generated/api.ts'; +import type { ChangePlanResult, WorkInProgressResult } from '#generated/api.ts'; import { invalidateAssistantAccessQueries } from '#features/assistant/api.svelte.ts'; +import { queryKeys as organizationQueryKeys } from '#features/organizations/api.svelte.ts'; import type { AdminAssistantSettings, AdminAssistantUsage, AdminEventSubmissionSettings, AdminStats, + AdminTrialSettings, ElasticsearchInfo, ElasticsearchSnapshotsResponse, MigrationRerunOperation, @@ -20,7 +22,8 @@ import type { UpdateAssistantConversationSharingSettingsRequest, UpdateAssistantEnabledSettingsRequest, UpdateAssistantSettingsRequest, - UpdateEventSubmissionSettingsRequest + UpdateEventSubmissionSettingsRequest, + UpdateTrialSettingsRequest } from './models'; export type GetOAuthApplicationsParams = { @@ -52,7 +55,8 @@ export const queryKeys = { oauthApplication: (id: string | undefined) => [...queryKeys.oauthApplications, id] as const, oauthApplications: ['admin', 'oauth-applications'] as const, snapshots: ['admin', 'elasticsearch', 'snapshots'] as const, - stats: ['admin', 'stats'] as const + stats: ['admin', 'stats'] as const, + trialSettings: ['admin', 'trial-settings'] as const }; export function deleteOAuthApplicationMutation() { @@ -290,6 +294,20 @@ export function getPredefinedSavedViewsMutation() { })); } +export function getTrialSettingsQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/trial-settings', { + signal + }); + + return response.data!; + }, + queryKey: queryKeys.trialSettings + })); +} + export function postForceUpdatePredefinedSavedViewsMutation() { return createMutation(() => ({ mutationFn: async () => { @@ -458,6 +476,22 @@ export function putPredefinedSavedViewsMutation() { })); } +export function putTrialSettingsMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async (request) => { + const client = useFetchClient(); + const response = await client.putJSON('admin/trial-settings', request); + + return response.data!; + }, + onSuccess: (settings) => { + queryClient.setQueryData(queryKeys.trialSettings, settings); + } + })); +} + export function runMaintenanceJobMutation() { return createMutation(() => ({ mutationFn: async (params: RunMaintenanceJobParams) => { @@ -472,3 +506,28 @@ export function runMaintenanceJobMutation() { } })); } + +export function setTrialEndMutation() { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + mutationFn: async ({ organizationId, trialEndsUtc }) => { + const client = useFetchClient(); + const response = await client.postJSON('admin/change-plan', undefined, { + params: { + organizationId, + planId: 'EX_TRIAL', + trialEndsUtc + } + }); + if (!response.data?.success) { + throw new Error(response.data?.message ?? 'Failed to update the trial end date.'); + } + }, + onSuccess: async (_, { organizationId }) => { + await queryClient.invalidateQueries({ + queryKey: organizationQueryKeys.id(organizationId, undefined) + }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/trial-settings.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/trial-settings.svelte new file mode 100644 index 0000000000..dccbdcec79 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/trial-settings.svelte @@ -0,0 +1,162 @@ + + +{#if settingsQuery.isPending} +
+ + Loading free trial settings... +
+{:else if settingsQuery.isError} +
+

Failed to load free trial settings.

+
+{:else if settings} + + {#snippet children(field)} + {@const enteredDomains = parseDomains(field.state.value)} + {@const hasChanges = enteredDomains.join('\n') !== settings.blocked_email_domains.join('\n')} + + +
+ Blocked trial email domains + {settings.is_default ? 'Built-in list' : 'Custom list'} +
+ + Signups from these domains and their subdomains get the Free plan instead of a free trial. Enter one domain per line. + +
+