Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public async Task<string> GetSubscriptionStatus(
return $"Plan: {(u.HasProAccess ? "Pro" : "Free")}\n" +
(u.IsTrialActive ? $"Trial active, ends: {u.TrialEndsAt:yyyy-MM-dd}\n" : "") +
(u.PlanExpiresAt is not null ? $"Plan expires: {u.PlanExpiresAt:yyyy-MM-dd}\n" : "") +
$"AI Messages: {u.AiMessagesUsedThisMonth}/{aiLimit}\n" +
$"Daily AI Messages: {u.AiMessagesUsedToday}/{aiLimit}\n" +
(u.IsLifetimePro ? "Lifetime Pro: Yes\n" : "") +
(u.SubscriptionInterval is not null ? $"Billing: {u.SubscriptionInterval.ToString()!.ToLowerInvariant()}" : "");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ Orbit has a free plan and a Pro plan. The free plan is fully usable for daily ha
## Limits on the free plan

- **Habits** are capped at **10** top-level habits. Sub-habits, completed habits, and soft-deleted habits don't count toward the cap. Pro removes the cap.
- **AI messages** are capped at **20** per month. Pro raises this to **500** per month.

Both plans can also earn a small bonus of extra AI messages from ad rewards, added on top of the plan limit.
- **AI messages** are capped at **5** per day. Pro raises this to **50** per day.

## What Pro unlocks

Expand Down
4 changes: 2 additions & 2 deletions src/Orbit.Application/Common/AppConfigKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public static class AppConfigKeys
public const string MaxReferrals = "MaxReferrals";
public const string FreeMaxHabits = "FreeMaxHabits";
public const string SubHabitsProOnly = "SubHabitsProOnly";
public const string FreeAiMessagesPerMonth = "FreeAiMessagesPerMonth";
public const string ProAiMessagesPerMonth = "ProAiMessagesPerMonth";
public const string FreeAiMessagesPerDay = "FreeAiMessagesPerDay";
public const string ProAiMessagesPerDay = "ProAiMessagesPerDay";
public const string DailySummaryProOnly = "DailySummaryProOnly";
public const string SmartRescheduleProOnly = "SmartRescheduleProOnly";
public const string RetrospectiveProOnly = "RetrospectiveProOnly";
Expand Down
4 changes: 2 additions & 2 deletions src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public static class AppConstants
public const int MaxHabitLogsReturned = 1000;
public const int DefaultReminderMinutes = 15;
public const int DefaultFreeMaxHabits = 10;
public const int DefaultFreeAiMessages = 20;
public const int DefaultProAiMessages = 500;
public const int DefaultFreeAiMessages = 5;
public const int DefaultProAiMessages = 50;
public const int MaxBulkOperationSize = 100;
public const int MaxGoalsPerHabit = 10;
public const int MaxHabitsPerGoal = 20;
Expand Down
41 changes: 20 additions & 21 deletions src/Orbit.Application/Common/PayGateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ namespace Orbit.Application.Common;
public class PayGateService(
IGenericRepository<Habit> habitRepository,
IGenericRepository<User> userRepository,
IAppConfigService appConfig) : IPayGateService
IAppConfigService appConfig,
IUserDateService userDateService) : IPayGateService
{
public async Task<Result> CanCreateHabits(Guid userId, int count = 1, CancellationToken ct = default)
{
Expand Down Expand Up @@ -51,16 +52,16 @@ public async Task<Result> CanSendAiMessage(Guid userId, CancellationToken ct = d
if (IsProductionSmokeAccount(user.Email))
return Result.Success();

var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct);
var baseLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = baseLimit + user.AdRewardBonusMessages;
var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct);
var messageLimit = user.HasProAccess ? proLimit : freeLimit;
var userToday = await userDateService.GetUserTodayAsync(userId, ct);

if (user.AiMessagesUsedThisMonth >= messageLimit)
if (user.AiMessagesLocalDate == userToday && user.AiMessagesUsedToday >= messageLimit)
{
var errorMessage = user.HasProAccess
? $"You've reached your monthly AI message limit ({messageLimit})."
: $"You've reached your monthly AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per month.";
? $"You've reached your daily AI message limit ({messageLimit})."
: $"You've reached your daily AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per day.";

return Result.PayGateFailure(errorMessage);
}
Expand All @@ -73,31 +74,30 @@ public async Task<Result> TryConsumeAiMessage(
IUnitOfWork unitOfWork,
CancellationToken ct = default)
{
var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct);
var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct);
var userToday = await userDateService.GetUserTodayAsync(userId, ct);

var consumption = await ConcurrencyRetry.ExecuteAsync(
userRepository,
unitOfWork,
token => userRepository.FindOneTrackedAsync(user => user.Id == userId, cancellationToken: token),
user =>
{
var currentAtUtc = DateTime.UtcNow;
if (!IsProductionSmokeAccount(user.Email))
{
var messageLimit = (user.HasProAccess ? proLimit : freeLimit) + user.AdRewardBonusMessages;
var cycleIsActive = user.AiMessagesResetAt.HasValue && user.AiMessagesResetAt.Value > currentAtUtc;
if (cycleIsActive && user.AiMessagesUsedThisMonth >= messageLimit)
var messageLimit = user.HasProAccess ? proLimit : freeLimit;
if (user.AiMessagesLocalDate == userToday && user.AiMessagesUsedToday >= messageLimit)
{
var errorMessage = user.HasProAccess
? $"You've reached your monthly AI message limit ({messageLimit})."
: $"You've reached your monthly AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per month.";
? $"You've reached your daily AI message limit ({messageLimit})."
: $"You've reached your daily AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per day.";

return Task.FromResult(Result.PayGateFailure(errorMessage));
}
}

user.IncrementAiMessageCount(currentAtUtc);
user.IncrementAiMessageCount(userToday);
return Task.FromResult(Result.Success());
},
ErrorMessages.UserNotFound,
Expand Down Expand Up @@ -210,10 +210,9 @@ public async Task<int> GetAiMessageLimit(Guid userId, CancellationToken ct = def
var user = await userRepository.GetByIdAsync(userId, ct);
if (user is null) return AppConstants.DefaultFreeAiMessages;

var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct);
var baseLimit = user.HasProAccess ? proLimit : freeLimit;
return baseLimit + user.AdRewardBonusMessages;
var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct);
var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct);
return user.HasProAccess ? proLimit : freeLimit;
}

private static bool IsProductionSmokeAccount(string email)
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Profile/Queries/GetProfileQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task<Result<ProfileResponse>> Handle(GetProfileQuery request, Cance
user.IsTrialActive,
user.TrialEndsAt,
user.PlanExpiresAt,
user.AiMessagesUsedThisMonth,
user.AiMessagesUsedToday,
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
aiMessageLimit,
user.HasImportedCalendar,
user.HasSeenImportPrompt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<Result<SubscriptionStatusResponse>> Handle(GetSubscriptionStat
user.IsTrialActive,
user.TrialEndsAt,
user.PlanExpiresAt,
user.AiMessagesUsedThisMonth,
user.AiMessagesUsedToday,
await payGate.GetAiMessageLimit(user.Id, cancellationToken),
user.IsLifetimePro,
user.SubscriptionInterval?.ToString().ToLowerInvariant(),
Expand Down
16 changes: 7 additions & 9 deletions src/Orbit.Domain/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public partial class User : Entity
public DateTime? PlanExpiresAt { get; private set; }
public DateTime? TrialEndsAt { get; private set; }
public bool IsLifetimePro { get; private set; } = false;
public int AiMessagesUsedThisMonth { get; private set; } = 0;
public DateTime? AiMessagesResetAt { get; private set; }
public int AiMessagesUsedToday { get; private set; } = 0;
public DateOnly? AiMessagesLocalDate { get; private set; }
public SubscriptionInterval? SubscriptionInterval { get; private set; }
public SubscriptionSource? SubscriptionSource { get; private set; }
public SubscriptionLapseReason? SubscriptionLapseReason { get; private set; }
Expand Down Expand Up @@ -342,17 +342,15 @@ private bool TryAcceptStripeEvent(DateTime? eventCreatedAtUtc, bool acceptEqualT

public void StartTrial(DateTime endsAt) => TrialEndsAt = endsAt;

public void IncrementAiMessageCount() => IncrementAiMessageCount(DateTime.UtcNow);

public void IncrementAiMessageCount(DateTime utcNow)
public void IncrementAiMessageCount(DateOnly userToday)
{
if (!AiMessagesResetAt.HasValue || AiMessagesResetAt.Value <= utcNow)
if (!AiMessagesLocalDate.HasValue || AiMessagesLocalDate.Value < userToday)
Comment thread
pullfrog[bot] marked this conversation as resolved.
{
AiMessagesUsedThisMonth = 0;
AiMessagesUsedToday = 0;
AdRewardBonusMessages = 0;
AiMessagesResetAt = utcNow.AddDays(30);
AiMessagesLocalDate = userToday;
}
AiMessagesUsedThisMonth++;
AiMessagesUsedToday++;
}

public Result GrantAdReward(DateOnly userToday, int bonusMessages = 5, int dailyCap = 3)
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Domain/Interfaces/IPayGateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public interface IPayGateService
Task<Result> CanCreateSubHabits(Guid userId, CancellationToken ct = default);

/// <summary>
/// Checks if the user can send AI messages (free: 20/month, Pro: 500/month).
/// Checks if the user can send AI messages (free: 5/day, Pro: 50/day).
/// </summary>
Task<Result> CanSendAiMessage(Guid userId, CancellationToken ct = default);

Expand Down
Loading
Loading