Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 architecture.html

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2929,7 +2929,7 @@
"Challenges": 14,
"Chat": 61,
"ChecklistTemplates": 9,
"Common": 184,
"Common": 185,
"Gamification": 38,
"Goals": 54,
"Habits": 87,
Expand Down Expand Up @@ -7589,14 +7589,16 @@
"testClass": "AiUsageSummaryServiceGenerationTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs",
"references": [
"AiUsageDaily"
"AiUsageDaily",
"User"
]
},
{
"testClass": "AiUsageSummaryServiceTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceTests.cs",
"references": [
"AiUsageDaily"
"AiUsageDaily",
"User"
]
},
{
Expand Down
4 changes: 3 additions & 1 deletion src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace Orbit.Api.Mcp.Tools;
public class SubscriptionTools(
IGenericRepository<User> userRepository,
IPayGateService payGate,
IUserDateService userDateService,
IMediator mediator,
IOptions<FrontendSettings> frontendSettings,
McpExecutorBridge executorBridge)
Expand All @@ -39,11 +40,12 @@ public async Task<string> GetSubscriptionStatus(
return $"Error: {ErrorMessages.UserNotFound.Message}";

var aiLimit = await payGate.GetAiMessageLimit(userId, cancellationToken);
var today = await userDateService.GetUserTodayAsync(userId, cancellationToken);

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.GetAiMessagesUsedToday(today)}/{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,15 +18,13 @@ Orbit has a free plan and a Pro plan. The free plan is fully usable for daily ha
## Limits on the free plan

- **Habits** have an abuse guard of **1000** live top-level habits on both the free and Pro plans. Sub-habits, completed habits, and soft-deleted habits don't count toward the guard.
- **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

Upgrading to Pro unlocks:

- Goals
- AI goal reviews
- Sub-habits
- The daily AI summary
- AI memory
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 = 1000;
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 @@ -48,16 +49,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 @@ -70,31 +71,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.GetAiMessagesUsedForQuota(userToday) >= 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 @@ -191,10 +191,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.GetAiMessagesUsedToday(today),
aiMessageLimit,
user.HasImportedCalendar,
user.HasSeenImportPrompt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ public record GetSubscriptionStatusQuery(Guid UserId) : IRequest<Result<Subscrip

public class GetSubscriptionStatusQueryHandler(
IGenericRepository<User> userRepository,
IPayGateService payGate) : IRequestHandler<GetSubscriptionStatusQuery, Result<SubscriptionStatusResponse>>
IPayGateService payGate,
IUserDateService userDateService) : IRequestHandler<GetSubscriptionStatusQuery, Result<SubscriptionStatusResponse>>
{
public async Task<Result<SubscriptionStatusResponse>> Handle(GetSubscriptionStatusQuery request, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
return Result.Failure<SubscriptionStatusResponse>(ErrorMessages.UserNotFound);

var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);

return Result.Success(new SubscriptionStatusResponse(
user.HasProAccess ? "pro" : "free",
user.HasProAccess,
user.IsTrialActive,
user.TrialEndsAt,
user.PlanExpiresAt,
user.AiMessagesUsedThisMonth,
user.GetAiMessagesUsedToday(today),
await payGate.GetAiMessageLimit(user.Id, cancellationToken),
user.IsLifetimePro,
user.SubscriptionInterval?.ToString().ToLowerInvariant(),
Expand Down
26 changes: 15 additions & 11 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,19 +342,23 @@ 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)
{
AiMessagesUsedThisMonth = 0;
AiMessagesUsedToday = 0;
AdRewardBonusMessages = 0;
AiMessagesResetAt = utcNow.AddDays(30);
AiMessagesLocalDate = userToday;
}
AiMessagesUsedThisMonth++;
AiMessagesUsedToday++;
}

public int GetAiMessagesUsedToday(DateOnly userToday) =>
AiMessagesLocalDate == userToday ? AiMessagesUsedToday : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the resolved date moves backward, GetAiMessagesUsedForQuota preserves the future bucket for enforcement, but this method reports zero through profile, subscription, and MCP. Both shipped chat composers then advertise available quota and submit a request that the server rejects until the local date catches up.

Technical details
# Keep reported usage aligned with effective enforcement

## Affected sites
- `src/Orbit.Domain/Entities/User.cs:356-360`: display usage requires exact date equality while enforcement counts a stored future bucket.
- `src/Orbit.Application/Profile/Queries/GetProfileQuery.cs:121`: exposes zero even when `TryConsumeAiMessage` will reject at the preserved limit.
- `src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs:30`: exposes the same inconsistent count.
- `src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs:48`: labels the inconsistent value as daily usage.
- `orbit-ui-mobile/apps/mobile/hooks/use-chat-composer.ts:153-155`: derives the local free-plan block from the reported count.
- `orbit-ui-mobile/apps/web/hooks/use-chat-composer.ts:177-179`: derives the same local block from the reported count.

## Required outcome
- Report the same effective usage that quota enforcement applies when the stored bucket is in the future, while still reporting zero for an expired previous-day bucket after normal midnight rollover.
- Cover a profile or subscription read after a backward local-date transition with the preserved bucket already at its limit.

## Suggested approach
- Reuse the effective quota projection for response surfaces instead of maintaining exact-match and monotonic projections with different behavior.


public int GetAiMessagesUsedForQuota(DateOnly userToday) =>
AiMessagesLocalDate >= userToday ? AiMessagesUsedToday : 0;

public Result GrantAdReward(DateOnly userToday, int bonusMessages = 5, int dailyCap = 3)
{
if (HasProAccess)
Expand Down Expand Up @@ -611,8 +615,8 @@ public Result ConsumeStreakFreeze()

/// <summary>
/// Resets progress and integration state (onboarding, gamification, calendar) to defaults while
/// preserving identity, preferences, subscription, and metered AI usage — the monthly message
/// quota and ad-reward allowances are kept so an account reset cannot refill the AI paygate.
/// preserving identity, preferences, subscription, and metered AI usage. The daily message
/// quota and ad reward allowances are kept so an account reset cannot refill the AI paygate.
/// </summary>
public void ResetAccount()
{
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