Skip to content
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2720,7 +2720,7 @@
"Challenges": 15,
"Chat": 80,
"ChecklistTemplates": 5,
"Common": 35,
"Common": 36,
"Gamification": 31,
"Goals": 35,
"Habits": 74,
Expand Down Expand Up @@ -2929,7 +2929,7 @@
"Challenges": 14,
"Chat": 61,
"ChecklistTemplates": 9,
"Common": 178,
"Common": 182,
"Gamification": 38,
"Goals": 54,
"Habits": 86,
Expand Down Expand Up @@ -4823,6 +4823,13 @@
"file": "tests/Orbit.Application.Tests/Common/ErrorCodesAndMessagesTests.cs",
"references": []
},
{
"testClass": "HabitCeilingLockTests",
"file": "tests/Orbit.Application.Tests/Common/HabitCeilingLockTests.cs",
"references": [
"Habit"
]
},
{
"testClass": "LocaleHelperTests",
"file": "tests/Orbit.Application.Tests/Common/LocaleHelperTests.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ derived_from:

# Free vs Pro

Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the limits and unlocks the advanced features.
Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the AI message limit and unlocks advanced features.

## 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.
- **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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using Orbit.Application.Chat.Models;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -149,6 +150,19 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel

var title = titleEl.GetString() ?? string.Empty;

return await HabitCeilingLock.ExecuteAsync(
unitOfWork,
userId,
transactionToken => ExecuteLockedAsync(args, userId, title, transactionToken),
ct);
}

private async Task<ToolResult> ExecuteLockedAsync(
JsonElement args,
Guid userId,
string title,
CancellationToken ct)
{
var habitGate = await payGate.CanCreateHabits(userId, 1, ct);
if (habitGate.IsFailure)
return ToolResult.FromFailure(habitGate);
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public static class AppConstants
public const int HabitLogsLookbackDays = 365;
public const int MaxHabitLogsReturned = 1000;
public const int DefaultReminderMinutes = 15;
public const int DefaultFreeMaxHabits = 10;
public const int DefaultFreeMaxHabits = 1000;
public const int DefaultFreeAiMessages = 20;
public const int DefaultProAiMessages = 500;
public const int MaxBulkOperationSize = 100;
Expand Down
25 changes: 25 additions & 0 deletions src/Orbit.Application/Common/HabitCeilingLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Common;

internal static class HabitCeilingLock
{
public static string ForUser(Guid userId) => $"habit-ceiling:{userId}";

public static Task AcquireAsync(
IUnitOfWork unitOfWork,
Guid userId,
CancellationToken cancellationToken) =>
unitOfWork.AcquireAdvisoryLockAsync(ForUser(userId), cancellationToken);

public static Task<T> ExecuteAsync<T>(
IUnitOfWork unitOfWork,
Guid userId,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken) =>
unitOfWork.ExecuteInTransactionAsync(async transactionToken =>
{
await AcquireAsync(unitOfWork, userId, transactionToken);
return await operation(transactionToken);
}, cancellationToken);
}
5 changes: 1 addition & 4 deletions src/Orbit.Application/Common/PayGateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,12 @@ public async Task<Result> CanCreateHabits(Guid userId, int count = 1, Cancellati
if (user is null)
return Result.Failure(ErrorMessages.UserNotFound);

if (user.HasProAccess)
return Result.Success();

var maxHabits = await appConfig.GetAsync(AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, ct);
var activeHabitCount = await habitRepository.CountAsync(
h => h.UserId == userId && h.ParentHabitId == null && !h.IsCompleted, ct);

if (activeHabitCount + count > maxHabits)
return Result.PayGateFailure($"You've reached the {maxHabits} habit limit on the free plan. Upgrade to Pro for unlimited habits.");
return Result.Failure($"You've reached the {maxHabits} habit limit.");

return Result.Success();
}
Expand Down
52 changes: 35 additions & 17 deletions src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ public partial class BulkCreateHabitsCommandHandler(
private const string DefaultTagColor = "#7c3aed";

public async Task<Result<BulkCreateResult>> Handle(BulkCreateHabitsCommand request, CancellationToken cancellationToken)
{
var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);
var result = await HabitCeilingLock.ExecuteAsync(
unitOfWork,
request.UserId,
ct => HandleLockedAsync(request, userToday, ct),
cancellationToken);

if (result.IsSuccess)
CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, userToday);

return result;
}

private async Task<Result<BulkCreateResult>> HandleLockedAsync(
BulkCreateHabitsCommand request,
DateOnly userToday,
CancellationToken cancellationToken)
{
var parentCount = request.Habits.Count;
var habitGate = await payGate.CanCreateHabits(request.UserId, parentCount, cancellationToken);
Expand All @@ -80,7 +98,6 @@ public async Task<Result<BulkCreateResult>> Handle(BulkCreateHabitsCommand reque
return subGate.PropagateError<BulkCreateResult>();
}

var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken);
var results = new List<BulkCreateItemResult>();

var existingRoots = await repos.Habits.FindAsync(
Expand All @@ -92,25 +109,26 @@ public async Task<Result<BulkCreateResult>> Handle(BulkCreateHabitsCommand reque

var tagsByName = await LoadTagCacheAsync(request, cancellationToken);

await unitOfWork.ExecuteInTransactionAsync(async ct =>
for (int i = 0; i < request.Habits.Count; i++)
{
for (int i = 0; i < request.Habits.Count; i++)
{
var itemResult = await CreateSingleHabit(
request.UserId, request.Habits[i], i, userToday, rootPositionCursor + i, tagsByName, ct);
results.Add(itemResult);
}

await unitOfWork.SaveChangesAsync(ct);
var itemResult = await CreateSingleHabit(
request.UserId,
request.Habits[i],
i,
userToday,
rootPositionCursor + i,
tagsByName,
cancellationToken);
results.Add(itemResult);
}

if (request.FromSyncReview)
{
await MarkSyncSuggestionsImported(request.UserId, results, ct);
await unitOfWork.SaveChangesAsync(ct);
}
}, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, userToday);
if (request.FromSyncReview)
{
await MarkSyncSuggestionsImported(request.UserId, results, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
}

return Result.Success(new BulkCreateResult(results));
}
Expand Down
11 changes: 10 additions & 1 deletion src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
IGenericRepository<Tag> TagRepository,
IGenericRepository<Goal> GoalRepository);

public partial class CreateHabitCommandHandler(

Check warning on line 37 in src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.
CreateHabitRepositories repos,
IUserDateService userDateService,
IPayGateService payGate,
Expand All @@ -44,7 +44,16 @@
IMemoryCache cache,
ILogger<CreateHabitCommandHandler> logger) : IRequestHandler<CreateHabitCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateHabitCommand request, CancellationToken cancellationToken)
public Task<Result<Guid>> Handle(CreateHabitCommand request, CancellationToken cancellationToken) =>
HabitCeilingLock.ExecuteAsync(
unitOfWork,
request.UserId,
ct => HandleLockedAsync(request, ct),
cancellationToken);

private async Task<Result<Guid>> HandleLockedAsync(
CreateHabitCommand request,
CancellationToken cancellationToken)
{
var opts = request.Options ?? new HabitCommandOptions();

Expand Down
11 changes: 10 additions & 1 deletion src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ public class DuplicateHabitCommandHandler(
IUserDateService userDateService,
IMemoryCache cache) : IRequestHandler<DuplicateHabitCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(DuplicateHabitCommand request, CancellationToken cancellationToken)
public Task<Result<Guid>> Handle(DuplicateHabitCommand request, CancellationToken cancellationToken) =>
HabitCeilingLock.ExecuteAsync(
unitOfWork,
request.UserId,
ct => HandleLockedAsync(request, ct),
cancellationToken);

private async Task<Result<Guid>> HandleLockedAsync(
DuplicateHabitCommand request,
CancellationToken cancellationToken)
{
var allHabits = await habitRepository.FindTrackedAsync(
h => h.UserId == request.UserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public record ApplyOnboardingCommand(

/// <summary>
/// Applies the buffer of answers a user built during pre-auth onboarding in a single transaction:
/// creates the habits (trimmed to the free-plan allowance), an optional first log, an optional
/// creates the habits (trimmed to the shared habit allowance), an optional first log, an optional
/// Pro-gated goal, week-start/color preferences, and flips <c>HasCompletedOnboarding</c>. Idempotent
/// by construction — an already-onboarded user is a no-op (<c>Applied:false</c>) — so the client can
/// flush unconditionally after any successful auth and retry safely under the concurrency pipeline.
Expand Down Expand Up @@ -89,7 +89,7 @@ public async Task<Result<ApplyOnboardingResponse>> Handle(
private async Task<Result<ApplyOnboardingResponse>> ApplyOnboardingTransactionAsync(
ApplyOnboardingCommand request, CancellationToken ct)
{
await unitOfWork.AcquireAdvisoryLockAsync($"onboarding-apply:{request.UserId}", ct);
await HabitCeilingLock.AcquireAsync(unitOfWork, request.UserId, ct);

var user = await repos.Users.FindOneTrackedAsync(
u => u.Id == request.UserId, cancellationToken: ct);
Expand Down Expand Up @@ -141,9 +141,6 @@ private async Task<Result<ApplyOnboardingResponse>> ApplyOnboardingTransactionAs
private async Task<IReadOnlyList<ApplyHabitInput>> TrimToAllowanceAsync(
User user, IReadOnlyList<ApplyHabitInput> habits, CancellationToken cancellationToken)
{
if (user.HasProAccess)
return habits;

var maxHabits = await appConfig.GetAsync(
AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, cancellationToken);
var existingRoots = await repos.Habits.CountAsync(
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 @@ -5,7 +5,7 @@ namespace Orbit.Domain.Interfaces;
public interface IPayGateService
{
/// <summary>
/// Checks if the user can create more habits (free plan: max 10 active).
/// Checks whether the shared live top-level habit ceiling allows the requested count.
/// </summary>
Task<Result> CanCreateHabits(Guid userId, int count = 1, CancellationToken ct = default);

Expand Down
Loading
Loading