From 12f4548e7ef70e7cfed60cde77d498743598aaaa Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:07:32 -0300 Subject: [PATCH 1/5] chore: initialize ORB-185 pull request From 9c65f70cd61550a5ccad017ad1b8fda2de004386 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:12:05 -0300 Subject: [PATCH 2/5] fix: exclude completed habits from free cap --- .../Content/FeatureExplanations/paygate.md | 2 +- .../Common/PayGateService.cs | 2 +- .../Commands/ApplyOnboardingCommand.cs | 2 +- .../ApplyOnboardingCommandHandlerTests.cs | 37 +++++++++++ .../Common/PayGateServiceTests.cs | 62 +++++++++++++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) 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/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index ced219e6..7e47789f 100644 --- a/src/Orbit.Application/Common/PayGateService.cs +++ b/src/Orbit.Application/Common/PayGateService.cs @@ -20,7 +20,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."); 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/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..44b450e8 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; @@ -43,6 +44,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 +92,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 +142,8 @@ 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] From 2e54df4959da0fe2d1392e22c0a8144813deacd2 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 02:15:32 -0300 Subject: [PATCH 3/5] fix: gate habit reactivation at free cap --- .../Habits/Commands/LogHabitCommand.cs | 15 ++- .../Habits/Commands/UpdateHabitCommand.cs | 20 +++ .../Common/PayGateServiceTests.cs | 123 +++++++++++++++++- 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 3d0a2c3c..2779cc36 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -55,7 +55,8 @@ public partial class LogHabitCommandHandler( LogHabitServices services, IUnitOfWork unitOfWork, IMemoryCache cache, - ILogger logger) : IRequestHandler> + ILogger logger, + IPayGateService? payGate = null) : IRequestHandler> { private const int MaxLogAttempts = 3; @@ -81,11 +82,23 @@ public async Task> Handle(LogHabitCommand request, Canc var existingLog = habit.Logs.FirstOrDefault(l => l.Date == targetDate && l.Value > 0); if (existingLog is not null && !habit.IsFlexible && !habit.IsBadHabit) + { + if (habit.IsCompleted && habit.ParentHabitId is null) + { + var allowanceGate = await GetPayGate().CanCreateHabits(request.UserId, 1, cancellationToken); + if (allowanceGate.IsFailure) + return allowanceGate.PropagateError(); + } + return await HandleUnlogAsync(habit, targetDate, today, cancellationToken); + } return await HandleLogAsync(habit, request, targetDate, today, user, cancellationToken); } + private IPayGateService GetPayGate() => + payGate ?? throw new InvalidOperationException("Habit reactivation allowance service is not configured."); + private static Result ValidateTargetDate(Habit habit, DateOnly targetDate, DateOnly today) { if (targetDate > today && habit.FrequencyUnit is not null) diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index cebbb788..68d6a7e1 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -71,6 +71,13 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c var opts = request.Options ?? new UpdateHabitCommandOptions(); + if (WouldReactivateTopLevelHabit(habit, request, opts)) + { + var allowanceGate = await payGate.CanCreateHabits(request.UserId, 1, cancellationToken); + if (allowanceGate.IsFailure) + return allowanceGate; + } + var result = habit.Update(new HabitUpdateParams( request.Title, request.Description, @@ -113,6 +120,19 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c return Result.Success(); } + private static bool WouldReactivateTopLevelHabit( + Habit habit, UpdateHabitCommand request, UpdateHabitCommandOptions options) + { + if (!habit.IsCompleted || habit.ParentHabitId is not null || request.FrequencyUnit is null) + return false; + + if (request.ClearEndDate == true) + return true; + + var dueDate = request.DueDate ?? habit.DueDate; + return options.EndDate.HasValue && dueDate <= options.EndDate.Value; + } + /// /// Rejects an IsGeneral change that would break the invariant that a habit's /// IsGeneral must match its parent's: if the habit has a parent, the new value must diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs index 44b450e8..2dbc7008 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs @@ -1,9 +1,14 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using NSubstitute; +using Orbit.Application.Challenges.Services; using Orbit.Application.Common; +using Orbit.Application.Habits.Commands; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; using System.Linq.Expressions; namespace Orbit.Application.Tests.Common; @@ -17,6 +22,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() { @@ -146,6 +152,105 @@ public async Task CanCreateHabits_FreeUser_AtLimit_PayGateFailure() "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 habitLogRepo = Substitute.For>(); + var goalRepo = Substitute.For>(); + var userDateService = Substitute.For(); + var userStreakService = Substitute.For(); + var gamificationService = Substitute.For(); + var challengeProgressService = Substitute.For(); + var mediator = Substitute.For(); + var unitOfWork = Substitute.For(); + using var cache = new MemoryCache(new MemoryCacheOptions()); + userDateService.GetUserTodayAsync(UserId, Arg.Any()) + .Returns(ReactivationToday); + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(CreateFreeUser()); + var handler = new LogHabitCommandHandler( + new LogHabitRepositories(_habitRepo, habitLogRepo, goalRepo, _userRepo), + new LogHabitServices( + userDateService, userStreakService, gamificationService, challengeProgressService, mediator), + unitOfWork, + cache, + Substitute.For>(), + _sut); + + var result = await handler.Handle( + new LogHabitCommand(UserId, habit.Id, 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(); + await unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [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; + var sentReminderRepo = Substitute.For>(); + var goalRepo = Substitute.For>(); + var userDateService = Substitute.For(); + var unitOfWork = Substitute.For(); + using var cache = new MemoryCache(new MemoryCacheOptions()); + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + var handler = new UpdateHabitCommandHandler( + _habitRepo, sentReminderRepo, goalRepo, _sut, userDateService, unitOfWork, cache); + var options = clearEndDate + ? null + : new UpdateHabitCommandOptions(EndDate: ReactivationToday.AddDays(7)); + var command = new UpdateHabitCommand( + UserId, + habit.Id, + "Changed title", + null, + FrequencyUnit.Day, + 1, + ClearEndDate: clearEndDate, + Options: options); + + var result = await handler.Handle(command, 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); + await unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + [Fact] public async Task CanCreateHabits_UserNotFound_Failure() { @@ -157,6 +262,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() { @@ -343,7 +459,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); @@ -475,7 +591,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); @@ -519,7 +635,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() From eaec4b0845acb4aed70c2f911da0e417be12943f Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 02:28:53 -0300 Subject: [PATCH 4/5] fix: keep reactivation gate architecture stable --- .../ServiceCollectionExtensions.AiServices.cs | 3 +- .../Common/PayGateService.cs | 60 ++++++++++++ .../Habits/Commands/LogHabitCommand.cs | 25 ++--- .../Habits/Commands/UpdateHabitCommand.cs | 70 ++++++-------- .../Common/PayGateServiceTests.cs | 91 ++++++------------- 5 files changed, 128 insertions(+), 121 deletions(-) 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/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index 7e47789f..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; @@ -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 2779cc36..b366d425 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -48,15 +48,15 @@ public record LogHabitServices( IUserStreakService UserStreakService, IGamificationService GamificationService, IChallengeProgressService ChallengeProgressService, - IMediator Mediator); + IMediator Mediator, + IPayGateService? PayGate = null); public partial class LogHabitCommandHandler( LogHabitRepositories repos, LogHabitServices services, IUnitOfWork unitOfWork, IMemoryCache cache, - ILogger logger, - IPayGateService? payGate = null) : IRequestHandler> + ILogger logger) : IRequestHandler> { private const int MaxLogAttempts = 3; @@ -82,23 +82,11 @@ public async Task> Handle(LogHabitCommand request, Canc var existingLog = habit.Logs.FirstOrDefault(l => l.Date == targetDate && l.Value > 0); if (existingLog is not null && !habit.IsFlexible && !habit.IsBadHabit) - { - if (habit.IsCompleted && habit.ParentHabitId is null) - { - var allowanceGate = await GetPayGate().CanCreateHabits(request.UserId, 1, cancellationToken); - if (allowanceGate.IsFailure) - return allowanceGate.PropagateError(); - } - return await HandleUnlogAsync(habit, targetDate, today, cancellationToken); - } return await HandleLogAsync(habit, request, targetDate, today, user, cancellationToken); } - private IPayGateService GetPayGate() => - payGate ?? throw new InvalidOperationException("Habit reactivation allowance service is not configured."); - private static Result ValidateTargetDate(Habit habit, DateOnly targetDate, DateOnly today) { if (targetDate > today && habit.FrequencyUnit is not null) @@ -126,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 68d6a7e1..40a14b84 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -71,33 +71,36 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c var opts = request.Options ?? new UpdateHabitCommandOptions(); - if (WouldReactivateTopLevelHabit(habit, request, opts)) - { - var allowanceGate = await payGate.CanCreateHabits(request.UserId, 1, cancellationToken); - if (allowanceGate.IsFailure) - return allowanceGate; - } - - 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; @@ -120,19 +123,6 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c return Result.Success(); } - private static bool WouldReactivateTopLevelHabit( - Habit habit, UpdateHabitCommand request, UpdateHabitCommandOptions options) - { - if (!habit.IsCompleted || habit.ParentHabitId is not null || request.FrequencyUnit is null) - return false; - - if (request.ClearEndDate == true) - return true; - - var dueDate = request.DueDate ?? habit.DueDate; - return options.EndDate.HasValue && dueDate <= options.EndDate.Value; - } - /// /// Rejects an IsGeneral change that would break the invariant that a habit's /// IsGeneral must match its parent's: if the habit has a parent, the new value must diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs index 2dbc7008..cd59bbd3 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs @@ -1,14 +1,9 @@ using FluentAssertions; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; using NSubstitute; -using Orbit.Application.Challenges.Services; using Orbit.Application.Common; -using Orbit.Application.Habits.Commands; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; -using Orbit.Domain.Models; using System.Linq.Expressions; namespace Orbit.Application.Tests.Common; @@ -160,44 +155,17 @@ public async Task UnlogCompletedTask_FreeUserAtLimit_RejectsWithoutChangingState UserId, "Finished task", null, null, ReactivationToday)).Value; habit.Log(ReactivationToday).IsSuccess.Should().BeTrue(); - var habitLogRepo = Substitute.For>(); - var goalRepo = Substitute.For>(); - var userDateService = Substitute.For(); - var userStreakService = Substitute.For(); - var gamificationService = Substitute.For(); - var challengeProgressService = Substitute.For(); - var mediator = Substitute.For(); - var unitOfWork = Substitute.For(); - using var cache = new MemoryCache(new MemoryCacheOptions()); - userDateService.GetUserTodayAsync(UserId, Arg.Any()) - .Returns(ReactivationToday); - _habitRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(habit); - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(CreateFreeUser()); - var handler = new LogHabitCommandHandler( - new LogHabitRepositories(_habitRepo, habitLogRepo, goalRepo, _userRepo), - new LogHabitServices( - userDateService, userStreakService, gamificationService, challengeProgressService, mediator), - unitOfWork, - cache, - Substitute.For>(), - _sut); - - var result = await handler.Handle( - new LogHabitCommand(UserId, habit.Id, ReactivationToday), CancellationToken.None); + 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(); - await unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); } [Theory] @@ -215,32 +183,28 @@ public async Task EndDateReactivation_FreeUserAtLimit_RejectsWithoutChangingStat EndDate: ReactivationToday)).Value; habit.Log(ReactivationToday).IsSuccess.Should().BeTrue(); var originalDueDate = habit.DueDate; - var sentReminderRepo = Substitute.For>(); - var goalRepo = Substitute.For>(); - var userDateService = Substitute.For(); - var unitOfWork = Substitute.For(); - using var cache = new MemoryCache(new MemoryCacheOptions()); - _habitRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(habit); - var handler = new UpdateHabitCommandHandler( - _habitRepo, sentReminderRepo, goalRepo, _sut, userDateService, unitOfWork, cache); - var options = clearEndDate - ? null - : new UpdateHabitCommandOptions(EndDate: ReactivationToday.AddDays(7)); - var command = new UpdateHabitCommand( - UserId, - habit.Id, - "Changed title", - null, - FrequencyUnit.Day, - 1, - ClearEndDate: clearEndDate, - Options: options); + DateOnly? endDate = clearEndDate ? null : ReactivationToday.AddDays(7); - var result = await handler.Handle(command, CancellationToken.None); + 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"); @@ -248,7 +212,6 @@ public async Task EndDateReactivation_FreeUserAtLimit_RejectsWithoutChangingStat habit.IsCompleted.Should().BeTrue(); habit.DueDate.Should().Be(originalDueDate); habit.EndDate.Should().Be(ReactivationToday); - await unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); } [Fact] From 0e211ce5bc5ecdd3e1f7f9ee870503b3c9405d05 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Fri, 7 Aug 2026 11:03:53 -0300 Subject: [PATCH 5/5] fix: gate update habit tool reactivation --- .../Tools/Implementations/UpdateHabitTool.cs | 16 ++++++- .../Chat/Tools/UpdateHabitToolTests.cs | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) 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/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;