diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs index 26527771..bbfc1229 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -125,7 +125,8 @@ private static void AddHabitCommandDependencies(WebApplicationBuilder builder) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); builder.Services.AddScoped(sp => new Orbit.Application.Habits.Commands.BulkLogServices( sp.GetRequiredService(), diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md index 48f4b6ee..ef28875e 100644 --- a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md @@ -17,7 +17,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 and soft-deleted habits don't count toward the cap. Pro removes the cap. +- **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. diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs index 4c648d83..21a5f7b3 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using Orbit.Application.Chat.Tools; +using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -9,7 +10,8 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class UpdateHabitTool( - IGenericRepository habitRepository) : IAiTool + IGenericRepository habitRepository, + IPayGateService? payGate = null) : IAiTool { public string Name => "update_habit"; @@ -96,7 +98,17 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel var updateParams = ResolveUpdateParams(args, habit); - var result = habit.Update(updateParams); + var result = await HabitReactivationAllowance.ExecuteAsync( + userId, + HabitReactivationAllowance.IsRequiredForEndDateChange( + habit, + updateParams.FrequencyUnit, + updateParams.DueDate, + updateParams.EndDate, + updateParams.ClearEndDate == true), + payGate, + () => habit.Update(updateParams), + ct); if (result.IsFailure) return ToolResult.FromFailure(result); diff --git a/src/Orbit.Application/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index ced219e6..68d8db6f 100644 --- a/src/Orbit.Application/Common/PayGateService.cs +++ b/src/Orbit.Application/Common/PayGateService.cs @@ -1,5 +1,6 @@ using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; namespace Orbit.Application.Common; @@ -20,7 +21,7 @@ public async Task CanCreateHabits(Guid userId, int count = 1, Cancellati var maxHabits = await appConfig.GetAsync(AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, ct); var activeHabitCount = await habitRepository.CountAsync( - h => h.UserId == userId && h.ParentHabitId == null, ct); + 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."); @@ -199,3 +200,62 @@ private async Task RequireProAccess(Guid userId, string errorMessage, Ca : Result.PayGateFailure(errorMessage); } } + +internal static class HabitReactivationAllowance +{ + public static bool IsRequiredForUnlog(Habit habit) => + habit.IsCompleted && habit.ParentHabitId is null; + + public static bool IsRequiredForEndDateChange( + Habit habit, + FrequencyUnit? frequencyUnit, + DateOnly? dueDate, + DateOnly? endDate, + bool clearEndDate) + { + if (!habit.IsCompleted || habit.ParentHabitId is not null || frequencyUnit is null) + return false; + + if (clearEndDate) + return true; + + return endDate.HasValue && (dueDate ?? habit.DueDate) <= endDate.Value; + } + + public static async Task> ExecuteAsync( + Guid userId, + bool requiresAllowance, + IPayGateService? payGate, + Func> transition, + CancellationToken cancellationToken) + { + if (requiresAllowance) + { + var allowanceGate = await GetPayGate(payGate).CanCreateHabits(userId, 1, cancellationToken); + if (allowanceGate.IsFailure) + return allowanceGate.PropagateError(); + } + + return transition(); + } + + public static async Task ExecuteAsync( + Guid userId, + bool requiresAllowance, + IPayGateService? payGate, + Func transition, + CancellationToken cancellationToken) + { + if (requiresAllowance) + { + var allowanceGate = await GetPayGate(payGate).CanCreateHabits(userId, 1, cancellationToken); + if (allowanceGate.IsFailure) + return allowanceGate; + } + + return transition(); + } + + private static IPayGateService GetPayGate(IPayGateService? payGate) => + payGate ?? throw new InvalidOperationException("Habit reactivation allowance service is not configured."); +} diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 3d0a2c3c..b366d425 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -48,7 +48,8 @@ public record LogHabitServices( IUserStreakService UserStreakService, IGamificationService GamificationService, IChallengeProgressService ChallengeProgressService, - IMediator Mediator); + IMediator Mediator, + IPayGateService? PayGate = null); public partial class LogHabitCommandHandler( LogHabitRepositories repos, @@ -113,7 +114,12 @@ private async Task> HandleUnlogAsync( var attempt = 1; while (true) { - var unlogResult = habit.Unlog(targetDate); + var unlogResult = await HabitReactivationAllowance.ExecuteAsync( + habit.UserId, + HabitReactivationAllowance.IsRequiredForUnlog(habit), + services.PayGate, + () => habit.Unlog(targetDate), + cancellationToken); if (unlogResult.IsFailure) return unlogResult.PropagateError(); unlogEntity = unlogResult.Value; diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index cebbb788..40a14b84 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -71,26 +71,36 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c var opts = request.Options ?? new UpdateHabitCommandOptions(); - var result = habit.Update(new HabitUpdateParams( - request.Title, - request.Description, - request.FrequencyUnit, - request.FrequencyQuantity, - opts.Days, - request.IsBadHabit, - request.DueDate, - DueTime: opts.DueTime, - DueEndTime: opts.DueEndTime, - ReminderEnabled: opts.ReminderEnabled, - ReminderTimes: opts.ReminderTimes, - SlipAlertEnabled: opts.SlipAlertEnabled, - ChecklistItems: opts.ChecklistItems, - IsGeneral: request.IsGeneral, - IsFlexible: opts.IsFlexible, - EndDate: opts.EndDate, - ClearEndDate: request.ClearEndDate, - ScheduledReminders: opts.ScheduledReminders, - Emoji: request.Emoji)); + var result = await HabitReactivationAllowance.ExecuteAsync( + request.UserId, + HabitReactivationAllowance.IsRequiredForEndDateChange( + habit, + request.FrequencyUnit, + request.DueDate, + opts.EndDate, + request.ClearEndDate == true), + payGate, + () => habit.Update(new HabitUpdateParams( + request.Title, + request.Description, + request.FrequencyUnit, + request.FrequencyQuantity, + opts.Days, + request.IsBadHabit, + request.DueDate, + DueTime: opts.DueTime, + DueEndTime: opts.DueEndTime, + ReminderEnabled: opts.ReminderEnabled, + ReminderTimes: opts.ReminderTimes, + SlipAlertEnabled: opts.SlipAlertEnabled, + ChecklistItems: opts.ChecklistItems, + IsGeneral: request.IsGeneral, + IsFlexible: opts.IsFlexible, + EndDate: opts.EndDate, + ClearEndDate: request.ClearEndDate, + ScheduledReminders: opts.ScheduledReminders, + Emoji: request.Emoji)), + cancellationToken); if (result.IsFailure) return result; diff --git a/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs index b89e0776..b08df146 100644 --- a/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs +++ b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs @@ -147,7 +147,7 @@ private async Task> TrimToAllowanceAsync( var maxHabits = await appConfig.GetAsync( AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, cancellationToken); var existingRoots = await repos.Habits.CountAsync( - h => h.UserId == user.Id && h.ParentHabitId == null, cancellationToken); + h => h.UserId == user.Id && h.ParentHabitId == null && !h.IsCompleted, cancellationToken); var allowance = Math.Max(0, maxHabits - existingRoots); return allowance >= habits.Count ? habits : habits.Take(allowance).ToList(); diff --git a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs index 47dbd282..89c5fd2f 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs @@ -5,6 +5,7 @@ using NSubstitute; using Orbit.Application.Chat.Tools; using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -38,6 +39,43 @@ public async Task SuccessfulUpdate_ReturnsSuccess() result.EntityName.Should().Be("Drink Water"); } + [Fact] + public async Task AtCapReactivation_ReturnsPayGateFailureWithoutMutatingHabit() + { + var habit = CreateCompletedRecurringHabit(); + SetupHabitFound(habit); + var payGate = Substitute.For(); + payGate.CanCreateHabits(UserId, 1, Arg.Any()) + .Returns(Result.PayGateFailure("Habit limit reached")); + var tool = new UpdateHabitTool(_habitRepo, payGate); + + var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement; + var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(Result.PayGateErrorCode); + habit.IsCompleted.Should().BeTrue(); + habit.EndDate.Should().Be(Today); + } + + [Fact] + public async Task AllowedReactivation_UpdatesCompletedHabit() + { + var habit = CreateCompletedRecurringHabit(); + SetupHabitFound(habit); + var payGate = Substitute.For(); + payGate.CanCreateHabits(UserId, 1, Arg.Any()) + .Returns(Result.Success()); + var tool = new UpdateHabitTool(_habitRepo, payGate); + + var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement; + var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None); + + result.Success.Should().BeTrue(); + habit.IsCompleted.Should().BeFalse(); + habit.EndDate.Should().BeNull(); + } + [Fact] public async Task HabitNotFound_ReturnsError() { @@ -586,6 +624,14 @@ private static Habit CreateHabit(string title, FrequencyUnit? freq, int? qty) return Habit.Create(new HabitCreateParams(UserId, title, freq, qty, DueDate: Today)).Value; } + private static Habit CreateCompletedRecurringHabit() + { + var habit = Habit.Create(new HabitCreateParams( + UserId, "Finished recurring habit", FrequencyUnit.Day, 1, DueDate: Today, EndDate: Today)).Value; + habit.AdvanceDueDate(Today); + return habit; + } + private static Habit CreateHabitWithTime(string title, FrequencyUnit? freq, int? qty, TimeOnly dueTime) { return Habit.Create(new HabitCreateParams(UserId, title, freq, qty, DueDate: Today, DueTime: dueTime)).Value; diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs index b94e5ee5..65366388 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs @@ -71,6 +71,15 @@ private static User CreateFreeUser() private static ApplyHabitInput Habit(string title) => new(title, null, null, FrequencyUnit.Day, 1); + private static Habit ExistingOneTimeTask(Guid userId, string title, bool completed) + { + var task = Orbit.Domain.Entities.Habit.Create(new HabitCreateParams( + userId, title, null, null, Today)).Value; + if (completed) + task.Log(Today).IsSuccess.Should().BeTrue(); + return task; + } + private static string SummaryCacheKey() => $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; @@ -177,6 +186,34 @@ public async Task Apply_FreeUserOverCap_TrimsToAllowance() user.HasCompletedOnboarding.Should().BeTrue(); } + [Fact] + public async Task Apply_FreeUserWithCompletedTasks_CreatesFullRequestedSet() + { + var user = CreateFreeUser(); + SetupUser(user); + var existingHabits = Enumerable.Range(1, 9) + .Select(index => ExistingOneTimeTask(user.Id, $"Finished task {index}", completed: true)) + .Append(ExistingOneTimeTask(user.Id, "Live task", completed: false)) + .ToList(); + _habitRepo.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => existingHabits.Count( + call.ArgAt>>(0).Compile())); + var command = new ApplyOnboardingCommand( + UserId, + [Habit("One"), Habit("Two"), Habit("Three"), Habit("Four"), Habit("Five")], + null, + null, + null, + null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.CreatedHabitCount.Should().Be(5); + } + [Fact] public async Task Apply_GoalGateFails_SkipsGoalButStillApplies() { diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs index 210739cb..cd59bbd3 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs @@ -4,6 +4,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using System.Linq.Expressions; namespace Orbit.Application.Tests.Common; @@ -16,6 +17,7 @@ public class PayGateServiceTests private readonly PayGateService _sut; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly ReactivationToday = new(2026, 8, 5); public PayGateServiceTests() { @@ -43,6 +45,29 @@ private static User CreateProUser() return user; } + private static Habit CreateCompletedOneTimeTask(int index) + { + var dueDate = new DateOnly(2026, 8, 5); + var task = Habit.Create(new HabitCreateParams( + UserId, $"Finished task {index}", null, null, dueDate)).Value; + task.Log(dueDate).IsSuccess.Should().BeTrue(); + return task; + } + + private static Habit CreateCompletedRecurringHabit(int index) + { + var dueDate = new DateOnly(2026, 8, 5); + var habit = Habit.Create(new HabitCreateParams( + UserId, + $"Finished recurring habit {index}", + FrequencyUnit.Day, + 1, + dueDate, + EndDate: dueDate)).Value; + habit.Log(dueDate).IsSuccess.Should().BeTrue(); + return habit; + } + [Fact] public async Task CanCreateHabits_ProUser_AlwaysSuccess() { @@ -68,6 +93,42 @@ public async Task CanCreateHabits_FreeUser_UnderLimit_Success() result.IsSuccess.Should().BeTrue(); } + [Fact] + public async Task CanCreateHabits_FreeUserWithCompletedTasks_Success() + { + var user = CreateFreeUser(); + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + var completedTasks = Enumerable.Range(1, 10).Select(CreateCompletedOneTimeTask).ToList(); + _habitRepo.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => completedTasks.Count( + call.ArgAt>>(0).Compile())); + + var result = await _sut.CanCreateHabits(UserId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CanCreateHabits_FreeUserWithCompletedRecurringHabits_Success() + { + var user = CreateFreeUser(); + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + var completedHabits = Enumerable.Range(1, 10).Select(CreateCompletedRecurringHabit).ToList(); + _habitRepo.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => completedHabits.Count( + call.ArgAt>>(0).Compile())); + + var result = await _sut.CanCreateHabits(UserId); + + result.IsSuccess.Should().BeTrue(); + } + [Fact] public async Task CanCreateHabits_FreeUser_AtLimit_PayGateFailure() { @@ -82,6 +143,75 @@ public async Task CanCreateHabits_FreeUser_AtLimit_PayGateFailure() result.IsFailure.Should().BeTrue(); result.ErrorCode.Should().Be("PAY_GATE"); + result.Error.Should().Be( + "You've reached the 10 habit limit on the free plan. Upgrade to Pro for unlimited habits."); + } + + [Fact] + public async Task UnlogCompletedTask_FreeUserAtLimit_RejectsWithoutChangingState() + { + ConfigureFreeUserAtHabitCap(); + var habit = Habit.Create(new HabitCreateParams( + UserId, "Finished task", null, null, ReactivationToday)).Value; + habit.Log(ReactivationToday).IsSuccess.Should().BeTrue(); + + var result = await HabitReactivationAllowance.ExecuteAsync( + UserId, + HabitReactivationAllowance.IsRequiredForUnlog(habit), + _sut, + () => habit.Unlog(ReactivationToday), + CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("PAY_GATE"); + habit.IsCompleted.Should().BeTrue(); + habit.Logs.Should().ContainSingle().Which.IsDeleted.Should().BeFalse(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task EndDateReactivation_FreeUserAtLimit_RejectsWithoutChangingState(bool clearEndDate) + { + ConfigureFreeUserAtHabitCap(); + var habit = Habit.Create(new HabitCreateParams( + UserId, + "Finished recurring habit", + FrequencyUnit.Day, + 1, + ReactivationToday, + EndDate: ReactivationToday)).Value; + habit.Log(ReactivationToday).IsSuccess.Should().BeTrue(); + var originalDueDate = habit.DueDate; + DateOnly? endDate = clearEndDate ? null : ReactivationToday.AddDays(7); + + var result = await HabitReactivationAllowance.ExecuteAsync( + UserId, + HabitReactivationAllowance.IsRequiredForEndDateChange( + habit, + FrequencyUnit.Day, + dueDate: null, + endDate, + clearEndDate), + _sut, + () => habit.Update(new HabitUpdateParams( + "Changed title", + null, + FrequencyUnit.Day, + 1, + null, + false, + null, + EndDate: endDate, + ClearEndDate: clearEndDate)), + CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("PAY_GATE"); + habit.Title.Should().Be("Finished recurring habit"); + habit.IsCompleted.Should().BeTrue(); + habit.DueDate.Should().Be(originalDueDate); + habit.EndDate.Should().Be(ReactivationToday); } [Fact] @@ -95,6 +225,17 @@ public async Task CanCreateHabits_UserNotFound_Failure() result.Error.Should().Contain("User not found"); } + private void ConfigureFreeUserAtHabitCap() + { + var user = CreateFreeUser(); + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _habitRepo.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(10); + } + [Fact] public async Task CanCreateSubHabits_ProUser_Success() { @@ -281,7 +422,7 @@ public async Task CanUseRetrospective_YearlyProUser_Success() [Fact] public async Task CanUseRetrospective_MonthlyProUser_PayGateFailure() { - var user = CreateProUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + var user = CreateProUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var result = await _sut.CanUseRetrospective(UserId); @@ -413,7 +554,7 @@ public async Task CanCreateApiKeys_UserNotFound_Failure() public async Task CanCreateHabits_TrialUser_HasProAccess() { var user = CreateFreeUser(); - user.StartTrial(DateTime.UtcNow.AddDays(7)); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + user.StartTrial(DateTime.UtcNow.AddDays(7)); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var result = await _sut.CanCreateHabits(UserId); @@ -457,7 +598,8 @@ public async Task GetAiMessageLimit_WithAdRewardBonus_IncludesBonus() var limit = await _sut.GetAiMessageLimit(UserId); - limit.Should().Be(25); } + limit.Should().Be(25); + } [Fact] public async Task CanCreateHabits_FreeUser_BulkCreate_ExceedsLimit_PayGateFailure()