diff --git a/Fin.Api/People/PersonController.cs b/Fin.Api/People/PersonController.cs new file mode 100644 index 0000000..02d9256 --- /dev/null +++ b/Fin.Api/People/PersonController.cs @@ -0,0 +1,61 @@ +using Fin.Application.People; +using Fin.Application.People.Dtos; +using Fin.Application.People.Enums; +using Fin.Domain.Global.Classes; +using Fin.Domain.People.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fin.Api.People; + +[Route("people")] +[Authorize] +public class PersonController(IPersonService service) : ControllerBase +{ + [HttpGet] + public async Task> GetList([FromQuery] PersonGetListInput input) + { + return await service.GetList(input); + } + + [HttpGet("{id:guid}")] + public async Task> Get([FromRoute] Guid id) + { + var category = await service.Get(id); + return category != null ? Ok(category) : NotFound(); + } + + [HttpPost] + public async Task> Create([FromBody] PersonInput input) + { + var validationResult = await service.Create(input, autoSave: true); + return validationResult.Success + ? Created($"categories/{validationResult.Data?.Id}", validationResult.Data) + : UnprocessableEntity(validationResult); + } + + [HttpPut("{id:guid}")] + public async Task Update([FromRoute] Guid id, [FromBody] PersonInput input) + { + var validationResult = await service.Update(id, input, autoSave: true); + return validationResult.Success + ? Ok() + : validationResult.ErrorCode == PersonCreateOrUpdateErrorCode.PersonNotFound + ? NotFound(validationResult) + : UnprocessableEntity(validationResult); + } + + [HttpPut("toggle-inactivated/{id:guid}")] + public async Task ToggleInactivated([FromRoute] Guid id) + { + var updated = await service.ToggleInactive(id, autoSave: true); + return updated ? Ok() : NotFound(); + } + + [HttpDelete("{id:guid}")] + public async Task Delete([FromRoute] Guid id) + { + var validation = await service.Delete(id, autoSave: true); + return validation.Success ? NoContent() : validation.ErrorCode == PersonDeleteErrorCode.PersonNotFound ? NotFound(validation): UnprocessableEntity(validation); + } +} \ No newline at end of file diff --git a/Fin.Api/Program.cs b/Fin.Api/Program.cs index d74c9bb..c6baa27 100644 --- a/Fin.Api/Program.cs +++ b/Fin.Api/Program.cs @@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args); var frontEndUrl = builder.Configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); +var version = builder.Configuration.GetSection(AppConstants.VersionConfigKey).Get(); builder.Services .AddInfrastructure(builder.Configuration) @@ -41,6 +42,14 @@ var app = builder.Build(); +if (!string.IsNullOrWhiteSpace(version)) +{ + var versionPathBase = version; + if (!versionPathBase.StartsWith("/")) versionPathBase = $"/{versionPathBase}"; + app.UsePathBase(versionPathBase); +} + + if (app.Environment.IsDevelopment()) { app.UseOpenApi(); @@ -55,11 +64,14 @@ await app.UseDbMigrations(); await app.UseSeeders(); -app.UseAuthentication(); -app.UseAuthorization(); +app.UseDefaultFiles(); +app.UseStaticFiles(); app.UseHsts(); app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/Fin.Api/Properties/launchSettings.json b/Fin.Api/Properties/launchSettings.json index 45a24e9..7ec1a1e 100644 --- a/Fin.Api/Properties/launchSettings.json +++ b/Fin.Api/Properties/launchSettings.json @@ -4,7 +4,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, "applicationUrl": "http://localhost:5045", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -13,7 +13,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, "applicationUrl": "https://localhost:7122", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Fin.Api/wwwroot/index.html b/Fin.Api/wwwroot/index.html new file mode 100644 index 0000000..64b38ea --- /dev/null +++ b/Fin.Api/wwwroot/index.html @@ -0,0 +1,287 @@ + + + + + + FinApp - API + + + + +
+
+ +

FinApp - API

+

Backend running successfully!

+
+ +
+
+ Checking status... +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/Fin.Application/HealthChecks/Services/HealthCheckService.cs b/Fin.Application/HealthChecks/Services/HealthCheckService.cs index 94a4dd1..ecb6e90 100644 --- a/Fin.Application/HealthChecks/Services/HealthCheckService.cs +++ b/Fin.Application/HealthChecks/Services/HealthCheckService.cs @@ -1,6 +1,7 @@ using Fin.Application.HealthChecks.Dtos; using Fin.Infrastructure.AutoServices; using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Constants; using Fin.Infrastructure.DateTimes; using Microsoft.Extensions.Configuration; @@ -19,7 +20,7 @@ public HealthCheckOutput GetHealthCheck() return new HealthCheckOutput { Status = "OK", - Version = configuration["ApiSettings:Version"] ?? "", + Version = configuration[AppConstants.VersionConfigKey] ?? "", Timestamp = dateTimeProvider.UtcNow() }; } diff --git a/Fin.Application/People/Dtos/PersonGetListInput.cs b/Fin.Application/People/Dtos/PersonGetListInput.cs new file mode 100644 index 0000000..04024e0 --- /dev/null +++ b/Fin.Application/People/Dtos/PersonGetListInput.cs @@ -0,0 +1,8 @@ +using Fin.Domain.Global.Classes; + +namespace Fin.Application.People.Dtos; + +public class PersonGetListInput: PagedFilteredAndSortedInput +{ + public bool? Inactivated { get; set; } +} \ No newline at end of file diff --git a/Fin.Application/People/Enums/PersonCreateOrUpdateErrorCode.cs b/Fin.Application/People/Enums/PersonCreateOrUpdateErrorCode.cs new file mode 100644 index 0000000..21593f2 --- /dev/null +++ b/Fin.Application/People/Enums/PersonCreateOrUpdateErrorCode.cs @@ -0,0 +1,18 @@ +using Fin.Infrastructure.Errors; + +namespace Fin.Application.People.Enums; + +public enum PersonCreateOrUpdateErrorCode +{ + [ErrorMessage("Name is required")] + NameIsRequired = 0, + + [ErrorMessage("Name already in use")] + NameAlreadyInUse = 1, + + [ErrorMessage("Name max lenght 100")] + NameTooLong = 2, + + [ErrorMessage("Person not found.")] + PersonNotFound = 4 +} \ No newline at end of file diff --git a/Fin.Application/People/Enums/PersonDeleteErrorCode.cs b/Fin.Application/People/Enums/PersonDeleteErrorCode.cs new file mode 100644 index 0000000..0be0a69 --- /dev/null +++ b/Fin.Application/People/Enums/PersonDeleteErrorCode.cs @@ -0,0 +1,12 @@ +using Fin.Infrastructure.Errors; + +namespace Fin.Application.People.Enums; + +public enum PersonDeleteErrorCode +{ + [ErrorMessage("Person in use.")] + PersonInUse = 0, + + [ErrorMessage("Person not found.")] + PersonNotFound = 1 +} \ No newline at end of file diff --git a/Fin.Application/People/TitleCategoryService.cs b/Fin.Application/People/TitleCategoryService.cs new file mode 100644 index 0000000..a3a0bb8 --- /dev/null +++ b/Fin.Application/People/TitleCategoryService.cs @@ -0,0 +1,127 @@ +using Fin.Application.Globals.Dtos; +using Fin.Application.People.Dtos; +using Fin.Application.People.Enums; +using Fin.Domain.Global.Classes; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Extensions; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.People; + +public interface IPersonService +{ + public Task Get(Guid id); + public Task> GetList(PersonGetListInput input); + public Task> Create(PersonInput input, bool autoSave = false); + public Task> Update(Guid id, PersonInput input, bool autoSave = false); + public Task> Delete(Guid id, bool autoSave = false); + public Task ToggleInactive(Guid id, bool autoSave = false); +} + +public class PersonService( + IRepository repository + ) : IPersonService, IAutoTransient +{ + public async Task Get(Guid id) + { + var entity = await repository.AsNoTracking().FirstOrDefaultAsync(n => n.Id == id); + return entity != null ? new PersonOutput(entity) : null; + } + + public async Task> GetList(PersonGetListInput input) + { + return await repository + .AsNoTracking() + .WhereIf(input.Inactivated.HasValue, n => n.Inactivated == input.Inactivated.Value) + .OrderBy(m => m.Inactivated) + .ThenBy(m => m.Name) + .ApplyFilterAndSorter(input) + .Select(n => new PersonOutput(n)) + .ToPagedResult(input); + } + + public async Task> Create(PersonInput input, bool autoSave = false) + { + var validation = await ValidateInput(input); + if (!validation.Success) return validation; + + var person = new Person(input); + await repository.AddAsync(person, autoSave); + validation.Data = new PersonOutput(person); + return validation; + } + + public async Task> Update(Guid id, PersonInput input, bool autoSave = false) + { + var validation = await ValidateInput(input, id); + if (!validation.Success) return validation; + + var person = await repository.FirstAsync(u => u.Id == id); + person.Update(input); + await repository.UpdateAsync(person, autoSave); + + validation.Data = true; + return validation; + } + + public async Task> Delete(Guid id, bool autoSave = false) + { + var validation = new ValidationResultDto(); + + var person = await repository + .Include(u => u.TitlePeople) + .FirstOrDefaultAsync(u => u.Id == id); + if (person == null) return validation.WithError(PersonDeleteErrorCode.PersonNotFound); + if (person.TitlePeople != null && person.TitlePeople.Any()) return validation.WithError(PersonDeleteErrorCode.PersonInUse); + + await repository.DeleteAsync(person, autoSave); + return validation.WithSuccess(true); + } + + public async Task ToggleInactive(Guid id, bool autoSave = false) + { + var person = await repository + .FirstOrDefaultAsync(u => u.Id == id); + if (person == null) return false; + + person.ToggleInactivated(); + await repository.UpdateAsync(person, autoSave); + + return true; + } + + private async Task> ValidateInput( PersonInput input, Guid? editingId = null) + { + var validationResult = new ValidationResultDto(); + + if (editingId.HasValue) + { + var titleExists = await repository.AnyAsync(n => n.Id == editingId.Value); + if (!titleExists) + return validationResult.WithError(PersonCreateOrUpdateErrorCode.PersonNotFound); + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + return validationResult.WithError(PersonCreateOrUpdateErrorCode.NameIsRequired); + } + if (input.Name.Length > 100) + { + return validationResult.WithError(PersonCreateOrUpdateErrorCode.NameTooLong); + } + var nameAlredInUse = await repository + .AnyAsync(n => n.Name == input.Name && (!editingId.HasValue || n.Id != editingId)); + if (nameAlredInUse) + { + validationResult.ErrorCode = PersonCreateOrUpdateErrorCode.NameAlreadyInUse; + validationResult.Message = "Name is already in use."; + return validationResult; + } + + validationResult.Success = true; + return validationResult; + } +} \ No newline at end of file diff --git a/Fin.Application/Titles/Dtos/TitleGetListInput.cs b/Fin.Application/Titles/Dtos/TitleGetListInput.cs index aa4b713..862a7f7 100644 --- a/Fin.Application/Titles/Dtos/TitleGetListInput.cs +++ b/Fin.Application/Titles/Dtos/TitleGetListInput.cs @@ -8,6 +8,8 @@ public class TitleGetListInput: PagedFilteredAndSortedInput { public List CategoryIds { get; set; } = []; public MultiplyFilterOperator CategoryOperator { get; set; } + public List PersonIds { get; set; } = []; + public MultiplyFilterOperator PersonOperator { get; set; } public List WalletIds { get; set; } = []; public TitleType? Type { get; set; } } \ No newline at end of file diff --git a/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs b/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs index 4671e8b..559b464 100644 --- a/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs +++ b/Fin.Application/Titles/Enums/TitleCreateOrUpdateErrorCode.cs @@ -36,4 +36,14 @@ public enum TitleCreateOrUpdateErrorCode [ErrorMessage("Duplicated title in same date time until minute.")] DuplicateTitleInSameDateTimeMinute = 10, + + [ErrorMessage("Some people was not found")] + SomePeopleNotFound = 11, + + [ErrorMessage("Some people is inactive")] + SomePeopleInactive = 12, + + [ErrorMessage("Financial split between people must be greater than or equal to 0 and less than or equal to 100")] + PeopleSplitRange = 13, + } \ No newline at end of file diff --git a/Fin.Application/Titles/Services/TitleService.cs b/Fin.Application/Titles/Services/TitleService.cs index 377ad2a..0aed1c8 100644 --- a/Fin.Application/Titles/Services/TitleService.cs +++ b/Fin.Application/Titles/Services/TitleService.cs @@ -43,8 +43,9 @@ IValidationPipelineOrchestrator validation { public async Task Get(Guid id, CancellationToken cancellationToken = default) { - var entity = await titleRepository.Query(false) + var entity = await titleRepository .Include(title => title.TitleCategories) + .Include(title => title.TitlePeople) .FirstOrDefaultAsync(n => n.Id == id, cancellationToken); return entity != null ? new TitleOutput(entity) : null; } @@ -52,14 +53,22 @@ public async Task Get(Guid id, CancellationToken cancellationToken public async Task> GetList(TitleGetListInput input, CancellationToken cancellationToken = default) { - return await titleRepository.Query(false) + return await titleRepository .Include(title => title.TitleCategories) + .Include(title => title.TitlePeople) .WhereIf(input.Type.HasValue, n => n.Type == input.Type) .WhereIf(input.WalletIds.Any(), title => input.WalletIds.Contains(title.WalletId)) + .WhereIf(input.CategoryIds.Any() && input.CategoryOperator == MultiplyFilterOperator.And, title => input.CategoryIds.All(id => title.TitleCategories.Any(c => c.Id == id))) .WhereIf(input.CategoryIds.Any() && input.CategoryOperator == MultiplyFilterOperator.Or, title => title.TitleCategories.Any(titleCategory => input.CategoryIds.Contains(titleCategory.Id))) + + .WhereIf(input.PersonIds.Any() && input.PersonOperator == MultiplyFilterOperator.And, title => + input.PersonIds.All(id => title.People.Any(c => c.Id == id))) + .WhereIf(input.PersonIds.Any() && input.PersonOperator == MultiplyFilterOperator.Or, + title => title.People.Any(titleCategory => input.PersonIds.Contains(titleCategory.Id))) + .ApplyDefaultTitleOrder() .ApplyFilterAndSorter(input) .Select(n => new TitleOutput(n)) @@ -92,6 +101,7 @@ public async Task> Updat var title = await titleRepository .Include(title => title.TitleTitleCategories) + .Include(title => title.TitlePeople) .FirstAsync(title => title.Id == id, cancellationToken); var mustReprocess = title.MustReprocess(input); @@ -99,7 +109,7 @@ public async Task> Updat await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) { - await updateHelpService.UpdateTitleAndCategories(title, input, context.CategoriesToRemove, cancellationToken); + await updateHelpService.PerformUpdateTitle(title, context, cancellationToken); if (mustReprocess) await updateHelpService.ReprocessAffectedWallets(title, context, autoSave: false, cancellationToken); if (autoSave) await scope.CompleteAsync(cancellationToken); } diff --git a/Fin.Application/Titles/Services/TitleUpdateHelpService.cs b/Fin.Application/Titles/Services/TitleUpdateHelpService.cs index efb4de5..a3bedb1 100644 --- a/Fin.Application/Titles/Services/TitleUpdateHelpService.cs +++ b/Fin.Application/Titles/Services/TitleUpdateHelpService.cs @@ -1,4 +1,5 @@ using Fin.Application.Wallets.Services; +using Fin.Domain.People.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Titles.Dtos; using Fin.Domain.Titles.Entities; @@ -10,10 +11,9 @@ namespace Fin.Application.Titles.Services; public interface ITitleUpdateHelpService { - Task UpdateTitleAndCategories( + Task PerformUpdateTitle( Title title, - TitleInput input, - List categoriesToRemove, + UpdateTitleContext context, CancellationToken cancellationToken); Task PrepareUpdateContext( @@ -55,20 +55,21 @@ Task> GetTitlesForReprocessing( public class TitleUpdateHelpService( IRepository titleRepository, IRepository<TitleTitleCategory> titleTitleCategoryRepository, + IRepository<TitlePerson> titlePeopleRepository, IWalletBalanceService balanceService ): ITitleUpdateHelpService, IAutoTransient { - public async Task UpdateTitleAndCategories( + public async Task PerformUpdateTitle( Title title, - TitleInput input, - List<TitleTitleCategory> categoriesToRemove, + UpdateTitleContext context, CancellationToken cancellationToken) { await titleRepository.UpdateAsync(title, cancellationToken); - foreach (var category in categoriesToRemove) - { + foreach (var category in context.CategoriesToRemove) await titleTitleCategoryRepository.DeleteAsync(category, cancellationToken); - } + foreach (var person in context.PeopleToRemove) + await titlePeopleRepository.DeleteAsync(person, cancellationToken); + } public async Task<UpdateTitleContext> PrepareUpdateContext( @@ -81,13 +82,16 @@ public async Task<UpdateTitleContext> PrepareUpdateContext( ? await CalculatePreviousBalance(title, input, cancellationToken) : title.PreviousBalance; - var categoriesToRemove = title.UpdateAndReturnCategoriesToRemove(input, previousBalance); + title.Update(input, previousBalance); + var categoriesToRemove = title.SyncCategoriesAndReturnToRemove(input.TitleCategoriesIds); + var peopleToRemove = title.SyncPeopleAndReturnToRemove(input.TitlePeople); return new UpdateTitleContext( PreviousWalletId: title.WalletId, PreviousDate: title.Date, PreviousBalance: title.PreviousBalance, - CategoriesToRemove: categoriesToRemove + CategoriesToRemove: categoriesToRemove, + PeopleToRemove: peopleToRemove ); } @@ -182,6 +186,6 @@ public record UpdateTitleContext( Guid PreviousWalletId, DateTime PreviousDate, decimal PreviousBalance, - List<TitleTitleCategory> CategoriesToRemove -); + List<TitleTitleCategory> CategoriesToRemove, + List<TitlePerson> PeopleToRemove); diff --git a/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputPeopleValidation.cs b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputPeopleValidation.cs new file mode 100644 index 0000000..2e4e196 --- /dev/null +++ b/Fin.Application/Titles/Validations/UpdateOrCrestes/TitleInputPeopleValidation.cs @@ -0,0 +1,84 @@ +using Fin.Application.Titles.Enums; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Fin.Infrastructure.ValidationsPipeline; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Titles.Validations.UpdateOrCrestes; + +public class TitleInputPeopleValidation( + IRepository<Title> titleRepository, + IRepository<Person> personRepository + ): IValidationRule<TitleInput, TitleCreateOrUpdateErrorCode, List<Guid>>, IAutoTransient +{ + public async Task<ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>> ValidateAsync(TitleInput input, Guid? editingId = null, CancellationToken cancellationToken = default) + { + var validation = new ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>>(); + + if (!input.TitlePeople.Any()) return validation; + + var people = await personRepository + .Where(person => input.TitlePeople.Select(tp => tp.PersonId).Contains(person.Id)) + .ToListAsync(cancellationToken); + + ValidatePeopleExistence(input, people, validation); + if (!validation.Success) return validation; + + var titleEditing = !editingId.HasValue ? null : await titleRepository + .Include(title => title.TitlePeople) + .FirstOrDefaultAsync(title => title.Id == editingId.Value, cancellationToken); + ValidatePeopleStatus(titleEditing, people, validation); + if (!validation.Success) return validation; + + ValidatePeopleSplitRange(input.TitlePeople, validation); + + return validation; + } + + private void ValidatePeopleExistence( + TitleInput input, + List<Person> people, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var foundPeopleIds = people.Select(person => person.Id).ToList(); + var notFoundPeople = input.TitlePeople + .Select(tp => tp.PersonId) + .Except(foundPeopleIds) + .ToList(); + + if (notFoundPeople.Any()) + validation.AddError(TitleCreateOrUpdateErrorCode.SomePeopleNotFound, notFoundPeople); + } + + private void ValidatePeopleStatus( + Title? titleEditing, + List<Person> people, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var previousPeopleIds = titleEditing?.TitlePeople? + .Select(tc => tc.PersonId)? + .ToList() ?? new List<Guid>(); + + var inactivePeopleIds = people + .Where(person => person.Inactivated + && !previousPeopleIds.Contains(person.Id)) + .Select(person => person.Id) + .ToList(); + + if (inactivePeopleIds.Any()) + validation.AddError(TitleCreateOrUpdateErrorCode.SomePeopleInactive, inactivePeopleIds); + } + + private void ValidatePeopleSplitRange( + List<TitlePersonInput> people, + ValidationPipelineOutput<TitleCreateOrUpdateErrorCode, List<Guid>> validation) + { + var splitSum = people.Sum(p => p.Percentage); + if (splitSum is > 100 or < 0.01m) + validation.AddError(TitleCreateOrUpdateErrorCode.PeopleSplitRange); + } +} \ No newline at end of file diff --git a/Fin.Domain/Global/Interfaces/IAudited.cs b/Fin.Domain/Global/Interfaces/IAudited.cs new file mode 100644 index 0000000..b944f93 --- /dev/null +++ b/Fin.Domain/Global/Interfaces/IAudited.cs @@ -0,0 +1,9 @@ +namespace Fin.Domain.Global.Interfaces; + +public interface IAudited +{ + public Guid CreatedBy { get; set; } + public Guid UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/Global/Interfaces/IAuditedEntity.cs b/Fin.Domain/Global/Interfaces/IAuditedEntity.cs index 570e202..185b345 100644 --- a/Fin.Domain/Global/Interfaces/IAuditedEntity.cs +++ b/Fin.Domain/Global/Interfaces/IAuditedEntity.cs @@ -1,9 +1,5 @@ namespace Fin.Domain.Global.Interfaces; -public interface IAuditedEntity: IEntity +public interface IAuditedEntity: IEntity, IAudited { - public Guid CreatedBy { get; set; } - public Guid UpdatedBy { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } } \ No newline at end of file diff --git a/Fin.Domain/Global/Interfaces/ITenant.cs b/Fin.Domain/Global/Interfaces/ITenant.cs new file mode 100644 index 0000000..0966103 --- /dev/null +++ b/Fin.Domain/Global/Interfaces/ITenant.cs @@ -0,0 +1,6 @@ +namespace Fin.Domain.Global.Interfaces; + +public interface ITenant +{ + public Guid TenantId { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/Global/Interfaces/ITenantEntity.cs b/Fin.Domain/Global/Interfaces/ITenantEntity.cs index a26541f..3617a83 100644 --- a/Fin.Domain/Global/Interfaces/ITenantEntity.cs +++ b/Fin.Domain/Global/Interfaces/ITenantEntity.cs @@ -1,6 +1,6 @@ namespace Fin.Domain.Global.Interfaces; -public interface ITenantEntity: IEntity +public interface ITenantEntity: IEntity, ITenant { - public Guid TenantId { get; set; } + } \ No newline at end of file diff --git a/Fin.Domain/People/Dtos/PersonInput.cs b/Fin.Domain/People/Dtos/PersonInput.cs new file mode 100644 index 0000000..4472f8a --- /dev/null +++ b/Fin.Domain/People/Dtos/PersonInput.cs @@ -0,0 +1,6 @@ +namespace Fin.Domain.People.Dtos; + +public class PersonInput +{ + public string Name { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/People/Dtos/PersonOutput.cs b/Fin.Domain/People/Dtos/PersonOutput.cs new file mode 100644 index 0000000..16736ed --- /dev/null +++ b/Fin.Domain/People/Dtos/PersonOutput.cs @@ -0,0 +1,10 @@ +using Fin.Domain.People.Entities; + +namespace Fin.Domain.People.Dtos; + +public class PersonOutput(Person person) +{ + public Guid Id { get; private set; } = person.Id; + public string Name { get; private set; } = person.Name; + public bool Inactivated { get; private set; } = person.Inactivated; +} \ No newline at end of file diff --git a/Fin.Domain/People/Dtos/TitlePersonInput.cs b/Fin.Domain/People/Dtos/TitlePersonInput.cs new file mode 100644 index 0000000..b594987 --- /dev/null +++ b/Fin.Domain/People/Dtos/TitlePersonInput.cs @@ -0,0 +1,7 @@ +namespace Fin.Domain.People.Dtos; + +public class TitlePersonInput +{ + public Guid PersonId { get; set; } + public decimal Percentage { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/People/Dtos/TitlePersonOutput.cs b/Fin.Domain/People/Dtos/TitlePersonOutput.cs new file mode 100644 index 0000000..343e37a --- /dev/null +++ b/Fin.Domain/People/Dtos/TitlePersonOutput.cs @@ -0,0 +1,9 @@ +using Fin.Domain.People.Entities; + +namespace Fin.Domain.People.Dtos; + +public class TitlePersonOutput(TitlePerson titlePerson) +{ + public Guid PersonId { get; set; } = titlePerson.PersonId; + public decimal Percentage {get; set;} = titlePerson.Percentage; +} \ No newline at end of file diff --git a/Fin.Domain/People/Entities/Person.cs b/Fin.Domain/People/Entities/Person.cs new file mode 100644 index 0000000..bea8b17 --- /dev/null +++ b/Fin.Domain/People/Entities/Person.cs @@ -0,0 +1,40 @@ +using Fin.Domain.Global.Interfaces; +using Fin.Domain.People.Dtos; +using Fin.Domain.Titles.Entities; + +namespace Fin.Domain.People.Entities; + +public class Person: IAuditedTenantEntity +{ + public string Name { get; private set; } + public bool Inactivated { get; private set; } + + public Guid Id { get; set; } + public Guid CreatedBy { get; set; } + public Guid UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public Guid TenantId { get; set; } + + public virtual ICollection<Title> Titles { get; set; } = []; + public virtual ICollection<TitlePerson> TitlePeople { get; set; } = []; + + public Person() + { + } + + public Person(PersonInput input) + { + Name = input.Name; + } + + public void Update(PersonInput input) + { + Name = input.Name; + } + + public void ToggleInactivated() + { + Inactivated = !Inactivated; + } +} \ No newline at end of file diff --git a/Fin.Domain/People/Entities/TitlePerson.cs b/Fin.Domain/People/Entities/TitlePerson.cs new file mode 100644 index 0000000..11e2709 --- /dev/null +++ b/Fin.Domain/People/Entities/TitlePerson.cs @@ -0,0 +1,38 @@ +using Fin.Domain.Global.Interfaces; +using Fin.Domain.People.Dtos; +using Fin.Domain.Titles.Entities; + +namespace Fin.Domain.People.Entities; + +public class TitlePerson: ITenant, IAudited +{ + public Guid PersonId { get; private set; } + public virtual Person Person { get; set; } + + public Guid TitleId { get; private set; } + public virtual Title Title { get; set; } + + public decimal Percentage {get; private set;} + + public TitlePerson() + { + } + + public TitlePerson(Guid titleId, TitlePersonInput titlePerson) + { + TitleId = titleId; + PersonId = titlePerson.PersonId; + Percentage = titlePerson.Percentage; + } + + public void Update(decimal percentage) + { + Percentage = percentage; + } + + public Guid TenantId { get; set; } + public Guid CreatedBy { get; set; } + public Guid UpdatedBy { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/Titles/Dtos/TitleInput.cs b/Fin.Domain/Titles/Dtos/TitleInput.cs index 3658a82..15abe65 100644 --- a/Fin.Domain/Titles/Dtos/TitleInput.cs +++ b/Fin.Domain/Titles/Dtos/TitleInput.cs @@ -1,3 +1,4 @@ +using Fin.Domain.People.Dtos; using Fin.Domain.Titles.Enums; namespace Fin.Domain.Titles.Dtos; @@ -10,4 +11,5 @@ public class TitleInput public DateTime Date { get; set; } public Guid WalletId { get; set; } public List<Guid> TitleCategoriesIds { get; set; } = []; + public List<TitlePersonInput> TitlePeople { get; set; } = []; } \ No newline at end of file diff --git a/Fin.Domain/Titles/Dtos/TitleOutput.cs b/Fin.Domain/Titles/Dtos/TitleOutput.cs index 0cc98e3..ee922fa 100644 --- a/Fin.Domain/Titles/Dtos/TitleOutput.cs +++ b/Fin.Domain/Titles/Dtos/TitleOutput.cs @@ -1,3 +1,4 @@ +using Fin.Domain.People.Dtos; using Fin.Domain.Titles.Entities; using Fin.Domain.Titles.Enums; @@ -16,6 +17,8 @@ public class TitleOutput(Title title) public Guid WalletId { get; set; } = title.WalletId; public List<Guid> TitleCategoriesIds { get; set; } = title.TitleCategories .Select(x => x.Id).ToList(); + public List<TitlePersonOutput> TitlePeople { get; set; } = title.TitlePeople + .Select(x => new TitlePersonOutput(x)).ToList(); public TitleOutput(): this(new Title()) { diff --git a/Fin.Domain/Titles/Entities/Title.cs b/Fin.Domain/Titles/Entities/Title.cs index 2faa694..63c17a7 100644 --- a/Fin.Domain/Titles/Entities/Title.cs +++ b/Fin.Domain/Titles/Entities/Title.cs @@ -1,5 +1,7 @@ using System.Collections.ObjectModel; using Fin.Domain.Global.Interfaces; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Titles.Dtos; using Fin.Domain.Titles.Enums; @@ -22,9 +24,13 @@ public class Title: IAuditedTenantEntity public decimal EffectiveValue => (Value * (Type == TitleType.Expense ? -1 : 1)); public virtual Wallet Wallet { get; set; } + public ICollection<TitleCategory> TitleCategories { get; set; } = []; public ICollection<TitleTitleCategory> TitleTitleCategories { get; set; } = []; + public ICollection<Person> People { get; set; } = []; + public ICollection<TitlePerson> TitlePeople { get; set; } = []; + public Guid Id { get; set; } public Guid CreatedBy { get; set; } @@ -42,7 +48,12 @@ public Title() { Id = Guid.NewGuid(); - UpdateBasicProperties(input, previousBalance); + Value = input.Value; + Type = input.Type; + Description = input.Description.Trim(); + Date = input.Date; + WalletId = input.WalletId; + PreviousBalance = previousBalance; TitleTitleCategories = new Collection<TitleTitleCategory>( input.TitleCategoriesIds @@ -50,12 +61,21 @@ public Title() .Select(categoryId => new TitleTitleCategory(categoryId, Id)) .ToList() ); + + TitlePeople = new Collection<TitlePerson>( + input.TitlePeople.DistinctBy(x => x.PersonId) + .Select(x => new TitlePerson(Id, x)) + .ToList()); } - public List<TitleTitleCategory> UpdateAndReturnCategoriesToRemove(TitleInput input, decimal previousBalance) + public void Update(TitleInput input, decimal previousBalance) { - UpdateBasicProperties(input, previousBalance); - return SyncCategories(input.TitleCategoriesIds); + Value = input.Value; + Type = input.Type; + Description = input.Description.Trim(); + Date = input.Date; + WalletId = input.WalletId; + PreviousBalance = previousBalance; } public bool MustReprocess(TitleInput input) @@ -66,17 +86,7 @@ public bool MustReprocess(TitleInput input) || input.WalletId != WalletId; } - private void UpdateBasicProperties(TitleInput input, decimal previousBalance) - { - Value = input.Value; - Type = input.Type; - Description = input.Description.Trim(); - Date = input.Date; - WalletId = input.WalletId; - PreviousBalance = previousBalance; - } - - private List<TitleTitleCategory> SyncCategories(List<Guid> newCategoryIds) + public List<TitleTitleCategory> SyncCategoriesAndReturnToRemove(List<Guid> newCategoryIds) { var updatedCategories = newCategoryIds.Select(userId => new TitleTitleCategory(userId, Id)).ToList(); @@ -102,4 +112,35 @@ private List<TitleTitleCategory> SyncCategories(List<Guid> newCategoryIds) return categoriesToDelete; } + + public List<TitlePerson> SyncPeopleAndReturnToRemove(List<TitlePersonInput> titlePersonInputs) + { + var updatedPeople = titlePersonInputs.Select(titlePerson => new TitlePerson(Id, titlePerson)).ToList(); + + var titlePeopleToDelete = new List<TitlePerson>(); + foreach (var currentPerson in TitlePeople) + { + var index = updatedPeople.FindIndex(c => c.PersonId == currentPerson.PersonId); + if (index != -1) + { + currentPerson.Update(updatedPeople[index].Percentage); + continue; + } + titlePeopleToDelete.Add(currentPerson); + } + + foreach (var currentDelivery in titlePeopleToDelete) + { + TitlePeople.Remove(currentDelivery); + } + + foreach (var updatePerson in updatedPeople) + { + var index = TitlePeople.ToList().FindIndex(c => c.PersonId == updatePerson.PersonId); + if (index != -1) continue; + TitlePeople.Add(updatePerson); + } + + return titlePeopleToDelete; + } } \ No newline at end of file diff --git a/Fin.Infrastructure/Constants/AppConstants.cs b/Fin.Infrastructure/Constants/AppConstants.cs index c9f27db..326a0af 100644 --- a/Fin.Infrastructure/Constants/AppConstants.cs +++ b/Fin.Infrastructure/Constants/AppConstants.cs @@ -4,4 +4,5 @@ public static class AppConstants { public const string AppName = "Fin App"; public const string FrontUrlConfigKey = "ApiSettings:FrontendConfigs:Url"; + public const string VersionConfigKey = "ApiSettings:Version"; } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/People/PeopleConfiguration.cs b/Fin.Infrastructure/Database/Configurations/People/PeopleConfiguration.cs new file mode 100644 index 0000000..706ed1d --- /dev/null +++ b/Fin.Infrastructure/Database/Configurations/People/PeopleConfiguration.cs @@ -0,0 +1,31 @@ +using Fin.Domain.People.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fin.Infrastructure.Database.Configurations.People; + +public class PeopleConfiguration: IEntityTypeConfiguration<Person> +{ + public void Configure(EntityTypeBuilder<Person> builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(100).IsRequired(); + builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique(); + + builder + .HasMany(x => x.Titles) + .WithMany(x => x.People) + .UsingEntity<TitlePerson>( + l => l + .HasOne(ttc => ttc.Title) + .WithMany(title => title.TitlePeople) + .HasForeignKey(e => e.TitleId) + .OnDelete(DeleteBehavior.Cascade), + r => r + .HasOne(ttc => ttc.Person) + .WithMany(category => category.TitlePeople) + .HasForeignKey(e => e.PersonId) + .OnDelete(DeleteBehavior.Cascade) + ); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/People/TitlePersonConfiguration.cs b/Fin.Infrastructure/Database/Configurations/People/TitlePersonConfiguration.cs new file mode 100644 index 0000000..a534c61 --- /dev/null +++ b/Fin.Infrastructure/Database/Configurations/People/TitlePersonConfiguration.cs @@ -0,0 +1,17 @@ +using Fin.Domain.People.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fin.Infrastructure.Database.Configurations.People; + +public class TitlePersonConfiguration: IEntityTypeConfiguration<TitlePerson> +{ + public void Configure(EntityTypeBuilder<TitlePerson> builder) + { + builder.HasKey(x => new { x.PersonId, x.TitleId }); + + builder.Property(x => x.Percentage) + .HasPrecision(5, 2) + .IsRequired(); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs b/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs index b132321..69a7c3c 100644 --- a/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/TitleCategories/TitleCategoryConfiguration.cs @@ -1,5 +1,4 @@ using Fin.Domain.TitleCategories.Entities; -using Fin.Domain.Titles.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; diff --git a/Fin.Infrastructure/Database/FinDbContext.cs b/Fin.Infrastructure/Database/FinDbContext.cs index c1c2d5e..fcd9635 100644 --- a/Fin.Infrastructure/Database/FinDbContext.cs +++ b/Fin.Infrastructure/Database/FinDbContext.cs @@ -76,7 +76,7 @@ private void ApplyTenantFilter(ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { - if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) + if (typeof(ITenant).IsAssignableFrom(entityType.ClrType)) { var method = typeof(FinDbContext) .GetMethod(nameof(SetTenantFilter), BindingFlags.NonPublic | BindingFlags.Instance) @@ -87,7 +87,7 @@ private void ApplyTenantFilter(ModelBuilder modelBuilder) } } - private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class, ITenantEntity + private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class, ITenant { if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") return; modelBuilder.Entity<TEntity>().HasQueryFilter(e => _ambientData.IsLogged && e.TenantId == _ambientData.TenantId); diff --git a/Fin.Infrastructure/Database/Interceptors/AuditedEntityInterceptor.cs b/Fin.Infrastructure/Database/Interceptors/AuditedEntityInterceptor.cs index 7589945..18a3bf9 100644 --- a/Fin.Infrastructure/Database/Interceptors/AuditedEntityInterceptor.cs +++ b/Fin.Infrastructure/Database/Interceptors/AuditedEntityInterceptor.cs @@ -31,7 +31,7 @@ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( var hasUserId = userId != Guid.Empty; - foreach (var entry in context.ChangeTracker.Entries<IAuditedEntity>()) + foreach (var entry in context.ChangeTracker.Entries<IAudited>()) { if (entry.State == EntityState.Added) { diff --git a/Fin.Infrastructure/Database/Interceptors/TenantEntityInterceptor.cs b/Fin.Infrastructure/Database/Interceptors/TenantEntityInterceptor.cs index 9b624d3..76fff85 100644 --- a/Fin.Infrastructure/Database/Interceptors/TenantEntityInterceptor.cs +++ b/Fin.Infrastructure/Database/Interceptors/TenantEntityInterceptor.cs @@ -28,7 +28,7 @@ public override ValueTask<InterceptionResult<int>> SavingChangesAsync( if (!hasTenantId) return base.SavingChangesAsync(eventData, result, cancellationToken); - foreach (var entry in context.ChangeTracker.Entries<ITenantEntity>()) + foreach (var entry in context.ChangeTracker.Entries<ITenant>()) { if (entry.State == EntityState.Added) { diff --git a/Fin.Infrastructure/Migrations/20251109225437_Person.Designer.cs b/Fin.Infrastructure/Migrations/20251109225437_Person.Designer.cs new file mode 100644 index 0000000..9e6f64e --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251109225437_Person.Designer.cs @@ -0,0 +1,1035 @@ +// <auto-generated /> +using System; +using Fin.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + [DbContext(typeof(FinDbContext))] + [Migration("20251109225437_Person")] + partial class Person + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fin.Domain.CardBrands.Entities.CardBrand", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("CardBrands", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<Guid>("CardBrandId") + .HasColumnType("uuid"); + + b.Property<int>("ClosingDay") + .HasColumnType("integer"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("DebitWalletId") + .HasColumnType("uuid"); + + b.Property<int>("DueDay") + .HasColumnType("integer"); + + b.Property<Guid>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CardBrandId"); + + b.HasIndex("DebitWalletId"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("CreditCards", "public"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactive") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<string>("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<bool>("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property<int>("Position") + .HasColumnType("integer"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Continuous") + .HasColumnType("boolean"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("HtmlBody") + .HasColumnType("text"); + + b.Property<string>("Link") + .HasColumnType("text"); + + b.Property<string>("NormalizedTextBody") + .HasColumnType("text"); + + b.Property<string>("NormalizedTitle") + .HasColumnType("text"); + + b.Property<int>("Severity") + .HasColumnType("integer"); + + b.Property<DateTime>("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("TextBody") + .HasColumnType("text"); + + b.Property<string>("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property<Guid>("NotificationId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Delivery") + .HasColumnType("boolean"); + + b.Property<bool>("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("AllowedWays") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<bool>("Enabled") + .HasColumnType("boolean"); + + b.Property<string>("FirebaseTokens") + .HasColumnType("text"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<TimeSpan>("NotifyOn") + .HasColumnType("interval"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<string>("Ways") + .HasColumnType("text"); + + b.Property<string>("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Person", "public"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.Property<Guid>("PersonId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Percentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("PersonId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitlePerson", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Locale") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<string>("Timezone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.Property<Guid>("TitleCategoryId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.HasKey("TitleCategoryId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitleTitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime>("Date") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<decimal>("PreviousBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<byte>("Type") + .HasColumnType("smallint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Value") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<Guid>("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("Titles", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateOnly?>("BirthDate") + .HasColumnType("date"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property<string>("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int>("Gender") + .HasColumnType("integer"); + + b.Property<string>("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<bool>("IsActivity") + .HasColumnType("boolean"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<string>("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property<int>("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property<string>("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EncryptedEmail") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Credentials", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("Aborted") + .HasColumnType("boolean"); + + b.Property<DateTime?>("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateOnly>("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property<DateTime>("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid?>("UserAbortedId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAbortedId"); + + b.HasIndex("UserId"); + + b.ToTable("UserDeleteRequests", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<Guid?>("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property<string>("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<decimal>("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.HasOne("Fin.Domain.CardBrands.Entities.CardBrand", "CardBrand") + .WithMany("CreditCards") + .HasForeignKey("CardBrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CardBrand"); + + b.Navigation("DebitWallet"); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.HasOne("Fin.Domain.Notifications.Entities.Notification", "Notification") + .WithMany("UserDeliveries") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.HasOne("Fin.Domain.People.Entities.Person", "Person") + .WithMany("TitlePeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitlePeople") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("Title"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.HasOne("Fin.Domain.Tenants.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.HasOne("Fin.Domain.TitleCategories.Entities.TitleCategory", "TitleCategory") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Title"); + + b.Navigation("TitleCategory"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "Wallet") + .WithMany("Titles") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithOne("Credential") + .HasForeignKey("Fin.Domain.Users.Entities.UserCredential", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "UserAborted") + .WithMany() + .HasForeignKey("UserAbortedId"); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany("DeleteRequests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserAborted"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("Wallets") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.CardBrands.Entities.CardBrand", b => + { + b.Navigation("CreditCards"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Wallets"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Navigation("UserDeliveries"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Navigation("TitlePeople"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Navigation("TitlePeople"); + + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Navigation("Credential"); + + b.Navigation("DeleteRequests"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Titles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251109225437_Person.cs b/Fin.Infrastructure/Migrations/20251109225437_Person.cs new file mode 100644 index 0000000..1539ebd --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251109225437_Person.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// <inheritdoc /> + public partial class Person : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Person", + schema: "public", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), + Inactivated = table.Column<bool>(type: "boolean", nullable: false), + CreatedBy = table.Column<Guid>(type: "uuid", nullable: false), + UpdatedBy = table.Column<Guid>(type: "uuid", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + TenantId = table.Column<Guid>(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Person", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TitlePerson", + schema: "public", + columns: table => new + { + PersonId = table.Column<Guid>(type: "uuid", nullable: false), + TitleId = table.Column<Guid>(type: "uuid", nullable: false), + Percentage = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false), + TenantId = table.Column<Guid>(type: "uuid", nullable: false), + CreatedBy = table.Column<Guid>(type: "uuid", nullable: false), + UpdatedBy = table.Column<Guid>(type: "uuid", nullable: false), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TitlePerson", x => new { x.PersonId, x.TitleId }); + table.ForeignKey( + name: "FK_TitlePerson_Person_PersonId", + column: x => x.PersonId, + principalSchema: "public", + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TitlePerson_Titles_TitleId", + column: x => x.TitleId, + principalSchema: "public", + principalTable: "Titles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Person_Name_TenantId", + schema: "public", + table: "Person", + columns: new[] { "Name", "TenantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TitlePerson_TitleId", + schema: "public", + table: "TitlePerson", + column: "TitleId"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TitlePerson", + schema: "public"); + + migrationBuilder.DropTable( + name: "Person", + schema: "public"); + } + } +} diff --git a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs index 3aacbe7..2e25d6f 100644 --- a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs +++ b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs @@ -395,6 +395,77 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserRememberUseSettings", "public"); }); + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<bool>("Inactivated") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Person", "public"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.Property<Guid>("PersonId") + .HasColumnType("uuid"); + + b.Property<Guid>("TitleId") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<decimal>("Percentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("PersonId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitlePerson", "public"); + }); + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => { b.Property<Guid>("Id") @@ -806,6 +877,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.HasOne("Fin.Domain.People.Entities.Person", "Person") + .WithMany("TitlePeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitlePeople") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("Title"); + }); + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => { b.HasOne("Fin.Domain.Tenants.Entities.Tenant", null) @@ -906,6 +996,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserDeliveries"); }); + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Navigation("TitlePeople"); + }); + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => { b.Navigation("TitleTitleCategories"); @@ -913,6 +1008,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => { + b.Navigation("TitlePeople"); + b.Navigation("TitleTitleCategories"); }); diff --git a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs index 357d3e8..92632fd 100644 --- a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs +++ b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs @@ -105,6 +105,17 @@ public async Task SeedAsync() OnlyForAdmin = false, Position = MenuPosition.LeftTop, KeyWords = "titles, títulos, lançamentos, gostos, recebidos" + }, + new() + { + Id = Guid.Parse("019aa9aa-55c4-72e5-931e-eb9a973670c8"), + FrontRoute = "/people", + Name = "finCore.features.person.title", + Color = "#fdc570", + Icon = "user", + OnlyForAdmin = false, + Position = MenuPosition.LeftTop, + KeyWords = "person, people, pessoas" } }; var defaultMenusIds = defaultMenus.Select(x => x.Id).ToList(); diff --git a/Fin.Test/People/PersonControllerTest.cs b/Fin.Test/People/PersonControllerTest.cs new file mode 100644 index 0000000..75cb936 --- /dev/null +++ b/Fin.Test/People/PersonControllerTest.cs @@ -0,0 +1,417 @@ +using Fin.Api.People; +using Fin.Application.Globals.Dtos; +using Fin.Application.People; +using Fin.Application.People.Dtos; +using Fin.Application.People.Enums; +using Fin.Domain.Global.Classes; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace Fin.Test.People; + +public class PersonControllerTest : TestUtils.BaseTest +{ + private readonly Mock<IPersonService> _serviceMock; + private readonly PersonController _controller; + + public PersonControllerTest() + { + _serviceMock = new Mock<IPersonService>(); + _controller = new PersonController(_serviceMock.Object); + } + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult() + { + // Arrange + var input = new PersonGetListInput { MaxResultCount = 10, SkipCount = 0 }; + var expectedResult = new PagedOutput<PersonOutput> + { + Items = new List<PersonOutput> + { + new(new Person(new PersonInput { Name = TestUtils.Strings[0] })), + new(new Person(new PersonInput { Name = TestUtils.Strings[1] })) + }, + TotalCount = 2 + }; + + _serviceMock + .Setup(s => s.GetList(input)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task GetList_ShouldReturnEmpty_WhenNoData() + { + // Arrange + var input = new PersonGetListInput { MaxResultCount = 10, SkipCount = 0 }; + var expectedResult = new PagedOutput<PersonOutput> + { + Items = new List<PersonOutput>(), + TotalCount = 0 + }; + + _serviceMock + .Setup(s => s.GetList(input)) + .ReturnsAsync(expectedResult); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + #endregion + + #region Get + + [Fact] + public async Task Get_ShouldReturnOk_WhenPersonExists() + { + // Arrange + var personId = TestUtils.Guids[0]; + var expectedPerson = new PersonOutput( + new Person(new PersonInput { Name = TestUtils.Strings[0] }) + { Id = personId }); + + _serviceMock + .Setup(s => s.Get(personId)) + .ReturnsAsync(expectedPerson); + + // Act + var result = await _controller.Get(personId); + + // Assert + result.Result.Should().BeOfType<OkObjectResult>() + .Which.Value.Should().Be(expectedPerson); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_WhenPersonDoesNotExist() + { + // Arrange + var personId = TestUtils.Guids[0]; + _serviceMock + .Setup(s => s.Get(personId)) + .ReturnsAsync((PersonOutput)null); + + // Act + var result = await _controller.Get(personId); + + // Assert + result.Result.Should().BeOfType<NotFoundResult>(); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnCreated_WhenInputIsValid() + { + // Arrange + var input = new PersonInput { Name = TestUtils.Strings[0] }; + var createdPerson = new PersonOutput( + new Person(input) { Id = TestUtils.Guids[0] }); + + var successResult = new ValidationResultDto<PersonOutput, PersonCreateOrUpdateErrorCode> + { + Success = true, + Data = createdPerson + }; + + _serviceMock + .Setup(s => s.Create(input, true)) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType<CreatedResult>() + .Which.Value.Should().Be(createdPerson); + + var createdResult = result.Result as CreatedResult; + createdResult.Location.Should().Be($"categories/{createdPerson.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var input = new PersonInput { Name = null }; + var failureResult = new ValidationResultDto<PersonOutput, PersonCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = PersonCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required" + }; + + _serviceMock + .Setup(s => s.Create(input, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + var unprocessableResult = result.Result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenNameAlreadyInUse() + { + // Arrange + var input = new PersonInput { Name = TestUtils.Strings[0] }; + var failureResult = new ValidationResultDto<PersonOutput, PersonCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = PersonCreateOrUpdateErrorCode.NameAlreadyInUse, + Message = "Name is already in use." + }; + + _serviceMock + .Setup(s => s.Create(input, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType<UnprocessableEntityObjectResult>(); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnOk_WhenUpdateIsSuccessful() + { + // Arrange + var personId = TestUtils.Guids[0]; + var input = new PersonInput { Name = TestUtils.Strings[1] }; + var successResult = new ValidationResultDto<bool, PersonCreateOrUpdateErrorCode> + { + Success = true, + Data = true + }; + + _serviceMock + .Setup(s => s.Update(personId, input, true)) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Update(personId, input); + + // Assert + result.Should().BeOfType<OkResult>(); + } + + [Fact] + public async Task Update_ShouldReturnNotFound_WhenPersonDoesNotExist() + { + // Arrange + var personId = TestUtils.Guids[0]; + var input = new PersonInput { Name = TestUtils.Strings[0] }; + var failureResult = new ValidationResultDto<bool, PersonCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = PersonCreateOrUpdateErrorCode.PersonNotFound + }; + + _serviceMock + .Setup(s => s.Update(personId, input, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(personId, input); + + // Assert + var notFoundResult = result.Should().BeOfType<NotFoundObjectResult>().Subject; + notFoundResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var personId = TestUtils.Guids[0]; + var input = new PersonInput { Name = null }; + var failureResult = new ValidationResultDto<bool, PersonCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = PersonCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required" + }; + + _serviceMock + .Setup(s => s.Update(personId, input, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(personId, input); + + // Assert + var unprocessableResult = result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenNameAlreadyInUse() + { + // Arrange + var personId = TestUtils.Guids[0]; + var input = new PersonInput { Name = TestUtils.Strings[0] }; + var failureResult = new ValidationResultDto<bool, PersonCreateOrUpdateErrorCode> + { + Success = false, + ErrorCode = PersonCreateOrUpdateErrorCode.NameAlreadyInUse, + Message = "Name is already in use." + }; + + _serviceMock + .Setup(s => s.Update(personId, input, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(personId, input); + + // Assert + result.Should().BeOfType<UnprocessableEntityObjectResult>(); + } + + #endregion + + #region ToggleInactivated + + [Fact] + public async Task ToggleInactivated_ShouldReturnOk_WhenPersonExists() + { + // Arrange + var personId = TestUtils.Guids[0]; + _serviceMock + .Setup(s => s.ToggleInactive(personId, true)) + .ReturnsAsync(true); + + // Act + var result = await _controller.ToggleInactivated(personId); + + // Assert + result.Should().BeOfType<OkResult>(); + } + + [Fact] + public async Task ToggleInactivated_ShouldReturnNotFound_WhenPersonDoesNotExist() + { + // Arrange + var personId = TestUtils.Guids[0]; + _serviceMock + .Setup(s => s.ToggleInactive(personId, true)) + .ReturnsAsync(false); + + // Act + var result = await _controller.ToggleInactivated(personId); + + // Assert + result.Should().BeOfType<NotFoundResult>(); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnNoContent_WhenPersonDeleted() + { + // Arrange + var personId = TestUtils.Guids[0]; + var successResult = new ValidationResultDto<bool, PersonDeleteErrorCode> + { + Success = true, + Data = true + }; + + _serviceMock + .Setup(s => s.Delete(personId, true)) + .ReturnsAsync(successResult); + + // Act + var result = await _controller.Delete(personId); + + // Assert + result.Should().BeOfType<NoContentResult>(); + } + + [Fact] + public async Task Delete_ShouldReturnNotFound_WhenPersonDoesNotExist() + { + // Arrange + var personId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto<bool, PersonDeleteErrorCode> + { + Success = false, + ErrorCode = PersonDeleteErrorCode.PersonNotFound + }; + + _serviceMock + .Setup(s => s.Delete(personId, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Delete(personId); + + // Assert + var notFoundResult = result.Should().BeOfType<NotFoundObjectResult>().Subject; + notFoundResult.Value.Should().BeEquivalentTo(failureResult); + } + + [Fact] + public async Task Delete_ShouldReturnUnprocessableEntity_WhenPersonInUse() + { + // Arrange + var personId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto<bool, PersonDeleteErrorCode> + { + Success = false, + ErrorCode = PersonDeleteErrorCode.PersonInUse, + Message = "Person in use." + }; + + _serviceMock + .Setup(s => s.Delete(personId, true)) + .ReturnsAsync(failureResult); + + // Act + var result = await _controller.Delete(personId); + + // Assert + var unprocessableResult = result + .Should().BeOfType<UnprocessableEntityObjectResult>() + .Subject; + unprocessableResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/People/PersonEntityTest.cs b/Fin.Test/People/PersonEntityTest.cs new file mode 100644 index 0000000..2176490 --- /dev/null +++ b/Fin.Test/People/PersonEntityTest.cs @@ -0,0 +1,135 @@ +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using FluentAssertions; + +namespace Fin.Test.People; + +public class PersonEntityTest +{ + #region Constructor + + [Fact] + public void Constructor_ShouldInitializeWithInput() + { + // Arrange + var input = new PersonInput + { + Name = TestUtils.Strings[0] + }; + + // Act + var person = new Person(input); + + // Assert + person.Should().NotBeNull(); + person.Name.Should().Be(TestUtils.Strings[0]); + person.Inactivated.Should().BeFalse(); + } + + [Fact] + public void Constructor_ShouldInitializeWithEmptyCollections() + { + // Arrange + var input = new PersonInput + { + Name = TestUtils.Strings[0] + }; + + // Act + var person = new Person(input); + + // Assert + person.Titles.Should().NotBeNull(); + person.Titles.Should().BeEmpty(); + person.TitlePeople.Should().NotBeNull(); + person.TitlePeople.Should().BeEmpty(); + } + + #endregion + + #region Update + + [Fact] + public void Update_ShouldUpdateName() + { + // Arrange + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + var updateInput = new PersonInput { Name = TestUtils.Strings[1] }; + + // Act + person.Update(updateInput); + + // Assert + person.Name.Should().Be(TestUtils.Strings[1]); + } + + [Fact] + public void Update_ShouldNotChangeInactivatedStatus() + { + // Arrange + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + person.ToggleInactivated(); + var updateInput = new PersonInput { Name = TestUtils.Strings[1] }; + + // Act + person.Update(updateInput); + + // Assert + person.Name.Should().Be(TestUtils.Strings[1]); + person.Inactivated.Should().BeTrue(); + } + + #endregion + + #region ToggleInactivated + + [Fact] + public void ToggleInactivated_ShouldChangeFromFalseToTrue() + { + // Arrange + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + person.Inactivated.Should().BeFalse(); + + // Act + person.ToggleInactivated(); + + // Assert + person.Inactivated.Should().BeTrue(); + } + + [Fact] + public void ToggleInactivated_ShouldChangeFromTrueToFalse() + { + // Arrange + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + person.ToggleInactivated(); + person.Inactivated.Should().BeTrue(); + + // Act + person.ToggleInactivated(); + + // Assert + person.Inactivated.Should().BeFalse(); + } + + [Fact] + public void ToggleInactivated_ShouldToggleMultipleTimes() + { + // Arrange + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + + // Act & Assert + person.Inactivated.Should().BeFalse(); + + person.ToggleInactivated(); + person.Inactivated.Should().BeTrue(); + + person.ToggleInactivated(); + person.Inactivated.Should().BeFalse(); + + person.ToggleInactivated(); + person.Inactivated.Should().BeTrue(); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/People/PersonServiceTest.cs b/Fin.Test/People/PersonServiceTest.cs new file mode 100644 index 0000000..9d569af --- /dev/null +++ b/Fin.Test/People/PersonServiceTest.cs @@ -0,0 +1,657 @@ +using Fin.Application.People; +using Fin.Application.People.Dtos; +using Fin.Application.People.Enums; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Test.People; + +public class PersonServiceTest : TestUtils.BaseTestWithContext +{ + #region Get + + [Fact] + public async Task Get_ShouldReturnPerson_WhenExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + // Act + var result = await service.Get(person.Id); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(person.Id); + result.Name.Should().Be(TestUtils.Strings[0]); + result.Inactivated.Should().BeFalse(); + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenNotExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.Get(TestUtils.Guids[0]); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult_WhenHasData() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + await resources.Repository.AddAsync( + new Person(new PersonInput { Name = "Person A" }), autoSave: true); + await resources.Repository.AddAsync( + new Person(new PersonInput { Name = "Person B" }), autoSave: true); + + var input = new PersonGetListInput { MaxResultCount = 10, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task GetList_ShouldReturnEmpty_WhenNoData() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonGetListInput { MaxResultCount = 10, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Should().NotBeNull(); + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + } + + [Fact] + public async Task GetList_ShouldOrderByInactivatedThenName() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var personC = new Person(new PersonInput { Name = "C Person" }); + var personA = new Person(new PersonInput { Name = "A Person" }); + var personB = new Person(new PersonInput { Name = "B Person" }); + personB.ToggleInactivated(); + + await resources.Repository.AddAsync(personC, autoSave: true); + await resources.Repository.AddAsync(personA, autoSave: true); + await resources.Repository.AddAsync(personB, autoSave: true); + + var input = new PersonGetListInput { MaxResultCount = 10, SkipCount = 0 }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Items.Should().HaveCount(3); + result.Items[0].Name.Should().Be("A Person"); + result.Items[0].Inactivated.Should().BeFalse(); + result.Items[1].Name.Should().Be("C Person"); + result.Items[1].Inactivated.Should().BeFalse(); + result.Items[2].Name.Should().Be("B Person"); + result.Items[2].Inactivated.Should().BeTrue(); + } + + [Fact] + public async Task GetList_ShouldFilterByInactivated_WhenTrue() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var activePerson = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + var inactivePerson = new Person(new PersonInput { Name = TestUtils.Strings[1] }); + inactivePerson.ToggleInactivated(); + + await resources.Repository.AddAsync(activePerson, autoSave: true); + await resources.Repository.AddAsync(inactivePerson, autoSave: true); + + var input = new PersonGetListInput + { + MaxResultCount = 10, + SkipCount = 0, + Inactivated = true + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Items.Should().HaveCount(1); + result.Items[0].Name.Should().Be(TestUtils.Strings[1]); + result.Items[0].Inactivated.Should().BeTrue(); + } + + [Fact] + public async Task GetList_ShouldFilterByInactivated_WhenFalse() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var activePerson = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + var inactivePerson = new Person(new PersonInput { Name = TestUtils.Strings[1] }); + inactivePerson.ToggleInactivated(); + + await resources.Repository.AddAsync(activePerson, autoSave: true); + await resources.Repository.AddAsync(inactivePerson, autoSave: true); + + var input = new PersonGetListInput + { + MaxResultCount = 10, + SkipCount = 0, + Inactivated = false + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Items.Should().HaveCount(1); + result.Items[0].Name.Should().Be(TestUtils.Strings[0]); + result.Items[0].Inactivated.Should().BeFalse(); + } + + [Fact] + public async Task GetList_ShouldReturnAll_WhenInactivatedIsNull() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var activePerson = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + var inactivePerson = new Person(new PersonInput { Name = TestUtils.Strings[1] }); + inactivePerson.ToggleInactivated(); + + await resources.Repository.AddAsync(activePerson, autoSave: true); + await resources.Repository.AddAsync(inactivePerson, autoSave: true); + + var input = new PersonGetListInput + { + MaxResultCount = 10, + SkipCount = 0, + Inactivated = null + }; + + // Act + var result = await service.GetList(input); + + // Assert + result.Items.Should().HaveCount(2); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonInput { Name = TestUtils.Strings[0] }; + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data.Name.Should().Be(TestUtils.Strings[0]); + result.Data.Inactivated.Should().BeFalse(); + + var dbPerson = await resources.Repository.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == result.Data.Id); + + dbPerson.Should().NotBeNull(); + dbPerson.Name.Should().Be(TestUtils.Strings[0]); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenNameIsRequired() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonInput { Name = null }; + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameIsRequired); + result.Data.Should().BeNull(); + + var count = await resources.Repository.AsNoTracking().CountAsync(); + count.Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Create_ShouldReturnFailure_WhenNameIsNullOrWhiteSpace(string name) + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonInput { Name = name }; + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameIsRequired); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenNameTooLong() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonInput { Name = new string('A', 101) }; + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameTooLong); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenNameAlreadyInUse() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var existingPerson = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(existingPerson, autoSave: true); + + var input = new PersonInput { Name = TestUtils.Strings[0] }; + + // Act + var result = await service.Create(input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnSuccess_WhenInputIsValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + var input = new PersonInput { Name = TestUtils.Strings[1] }; + + // Act + var result = await service.Update(person.Id, input, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + + var dbPerson = await resources.Repository.AsNoTracking() + .FirstAsync(p => p.Id == person.Id); + + dbPerson.Name.Should().Be(TestUtils.Strings[1]); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenPersonNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = new PersonInput { Name = TestUtils.Strings[0] }; + + // Act + var result = await service.Update(TestUtils.Guids[0], input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.PersonNotFound); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Update_ShouldReturnFailure_WhenNameIsNullOrWhiteSpace(string name) + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + var input = new PersonInput { Name = name }; + + // Act + var result = await service.Update(person.Id, input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameIsRequired); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenNameTooLong() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + var input = new PersonInput { Name = new string('A', 101) }; + + // Act + var result = await service.Update(person.Id, input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameTooLong); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenNameAlreadyInUseByOther() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[1] }); + await resources.Repository.AddAsync(person1, autoSave: true); + await resources.Repository.AddAsync(person2, autoSave: true); + + var input = new PersonInput { Name = TestUtils.Strings[0] }; + + // Act + var result = await service.Update(person2.Id, input, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonCreateOrUpdateErrorCode.NameAlreadyInUse); + } + + [Fact] + public async Task Update_ShouldReturnSuccess_WhenNameUnchanged() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + var input = new PersonInput { Name = TestUtils.Strings[0] }; + + // Act + var result = await service.Update(person.Id, input, autoSave: true); + + // Assert + result.Success.Should().BeTrue(); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnSuccess_WhenPersonExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + // Act + var result = await service.Delete(person.Id, autoSave: true); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + + var dbPerson = await resources.Repository.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == person.Id); + + dbPerson.Should().BeNull(); + } + + [Fact] + public async Task Delete_ShouldReturnFailure_WhenPersonNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.Delete(TestUtils.Guids[0], autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonDeleteErrorCode.PersonNotFound); + } + + [Fact] + public async Task Delete_ShouldReturnFailure_WhenPersonInUse() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[4], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[1], + }); + var title = new Title + { + Id = TestUtils.Guids[1], + Description = TestUtils.Strings[3], + Wallet = wallet, + Value = 10.0m, + }; + await GetRepository<Wallet>().AddAsync(wallet, autoSave: true); + await GetRepository<Title>().AddAsync(title, autoSave: true); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + // Simulate TitlePerson relationship + var titlePerson = new TitlePerson(title.Id, new TitlePersonInput{ PersonId = person.Id, Percentage = 100m }); + person.TitlePeople.Add(titlePerson); + await resources.Repository.UpdateAsync(person, autoSave: true); + + // Act + var result = await service.Delete(person.Id, autoSave: true); + + // Assert + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(PersonDeleteErrorCode.PersonInUse); + + var dbPerson = await resources.Repository.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == person.Id); + + dbPerson.Should().NotBeNull(); + } + + #endregion + + #region ToggleInactive + + [Fact] + public async Task ToggleInactive_ShouldReturnTrue_WhenPersonExists() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + person.Inactivated.Should().BeFalse(); + + // Act + var result = await service.ToggleInactive(person.Id, autoSave: true); + + // Assert + result.Should().BeTrue(); + + var dbPerson = await resources.Repository.AsNoTracking() + .FirstAsync(p => p.Id == person.Id); + + dbPerson.Inactivated.Should().BeTrue(); + } + + [Fact] + public async Task ToggleInactive_ShouldToggleBackToFalse() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person = new Person(new PersonInput { Name = TestUtils.Strings[0] }); + await resources.Repository.AddAsync(person, autoSave: true); + + await service.ToggleInactive(person.Id, autoSave: true); + var dbPerson1 = await resources.Repository.AsNoTracking() + .FirstAsync(p => p.Id == person.Id); + dbPerson1.Inactivated.Should().BeTrue(); + + // Act + var result = await service.ToggleInactive(person.Id, autoSave: true); + + // Assert + result.Should().BeTrue(); + + var dbPerson2 = await resources.Repository.AsNoTracking() + .FirstAsync(p => p.Id == person.Id); + + dbPerson2.Inactivated.Should().BeFalse(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnFalse_WhenPersonNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + // Act + var result = await service.ToggleInactive(TestUtils.Guids[0], autoSave: true); + + // Assert + result.Should().BeFalse(); + } + + #endregion + + private PersonService GetService(Resources resources) + { + return new PersonService(resources.Repository); + } + + private Resources GetResources() + { + return new Resources + { + Repository = GetRepository<Person>() + }; + } + + private class Resources + { + public IRepository<Person> Repository { get; set; } + } +} \ No newline at end of file diff --git a/Fin.Test/Titles/TitleServiceTest.cs b/Fin.Test/Titles/TitleServiceTest.cs index 16dab1a..d53a6c7 100644 --- a/Fin.Test/Titles/TitleServiceTest.cs +++ b/Fin.Test/Titles/TitleServiceTest.cs @@ -2,6 +2,7 @@ using Fin.Application.Titles.Enums; using Fin.Application.Titles.Services; using Fin.Application.Wallets.Services; +using Fin.Domain.People.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Titles.Dtos; using Fin.Domain.Titles.Entities; @@ -445,7 +446,8 @@ public async Task Update_ShouldReturnSuccess_WhenInputIsValid() PreviousWalletId: wallet.Id, PreviousDate: title.Date, PreviousBalance: title.PreviousBalance, - CategoriesToRemove: new List<TitleTitleCategory>() + CategoriesToRemove: new List<TitleTitleCategory>(), + PeopleToRemove: new List<TitlePerson>() ); _updateHelpServiceMock @@ -457,10 +459,9 @@ public async Task Update_ShouldReturnSuccess_WhenInputIsValid() .ReturnsAsync(context); _updateHelpServiceMock - .Setup(u => u.UpdateTitleAndCategories( + .Setup(u => u.PerformUpdateTitle( It.IsAny<Title>(), - updateInput, - It.IsAny<List<TitleTitleCategory>>(), + It.IsAny<UpdateTitleContext>(), It.IsAny<CancellationToken>())) .Returns(Task.CompletedTask); diff --git a/Fin.Test/Titles/TitleTest.cs b/Fin.Test/Titles/TitleTest.cs index 755ad15..f096172 100644 --- a/Fin.Test/Titles/TitleTest.cs +++ b/Fin.Test/Titles/TitleTest.cs @@ -1,3 +1,4 @@ +using Fin.Domain.People.Dtos; using Fin.Domain.Titles.Dtos; using Fin.Domain.Titles.Entities; using Fin.Domain.Titles.Enums; @@ -237,10 +238,10 @@ public void EffectiveValue_ShouldBeNegative_ForExpense() #endregion - #region UpdateAndReturnCategoriesToRemove + #region Update [Fact] - public void UpdateAndReturnCategoriesToRemove_ShouldUpdateBasicProperties() + public void Update_ShouldUpdateBasicProperties() { // Arrange var initialInput = new TitleInput @@ -266,7 +267,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldUpdateBasicProperties() var newPreviousBalance = 150m; // Act - var result = title.UpdateAndReturnCategoriesToRemove(updateInput, newPreviousBalance); + title.Update(updateInput, newPreviousBalance); // Assert title.Value.Should().Be(200m); @@ -275,11 +276,10 @@ public void UpdateAndReturnCategoriesToRemove_ShouldUpdateBasicProperties() title.Date.Should().Be(updateInput.Date); title.WalletId.Should().Be(TestUtils.Guids[2]); title.PreviousBalance.Should().Be(150m); - result.Should().NotBeNull(); } [Fact] - public void UpdateAndReturnCategoriesToRemove_ShouldAddNewCategories() + public void SyncCategoriesAndReturnToRemove_ShouldAddNewCategories() { // Arrange var initialInput = new TitleInput @@ -304,7 +304,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldAddNewCategories() }; // Act - var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + var result = title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); // Assert title.TitleTitleCategories.Should().HaveCount(3); @@ -315,7 +315,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldAddNewCategories() } [Fact] - public void UpdateAndReturnCategoriesToRemove_ShouldRemoveCategories() + public void SyncCategoriesAndReturnToRemove_ShouldRemoveCategories() { // Arrange var initialInput = new TitleInput @@ -340,7 +340,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldRemoveCategories() }; // Act - var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + var result = title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); // Assert title.TitleTitleCategories.Should().HaveCount(1); @@ -351,7 +351,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldRemoveCategories() } [Fact] - public void UpdateAndReturnCategoriesToRemove_ShouldKeepExistingCategories() + public void SyncCategoriesAndReturnToRemove_ShouldKeepExistingCategories() { // Arrange var initialInput = new TitleInput @@ -376,7 +376,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldKeepExistingCategories() }; // Act - var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + var result = title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); // Assert title.TitleTitleCategories.Should().HaveCount(2); @@ -386,7 +386,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldKeepExistingCategories() } [Fact] - public void UpdateAndReturnCategoriesToRemove_ShouldRemoveAllCategories() + public void Update_ShouldRemoveAllCategories() { // Arrange var initialInput = new TitleInput @@ -411,7 +411,7 @@ public void UpdateAndReturnCategoriesToRemove_ShouldRemoveAllCategories() }; // Act - var result = title.UpdateAndReturnCategoriesToRemove(updateInput, 0); + var result = title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); // Assert title.TitleTitleCategories.Should().BeEmpty(); @@ -419,6 +419,229 @@ public void UpdateAndReturnCategoriesToRemove_ShouldRemoveAllCategories() } #endregion + + #region SyncPeopleAndReturnToRemove + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldAddNewPeople() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 50m } + } + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 50m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 30m }, + new() { PersonId = TestUtils.Guids[3], Percentage = 20m } + }; + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().HaveCount(3); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[1]); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[2]); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[3]); + + result.Should().HaveCount(0); +} + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldRemovePeople() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 40m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 30m }, + new() { PersonId = TestUtils.Guids[3], Percentage = 30m } + } + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 100m } + }; + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().HaveCount(1); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[1]); + + result.Should().HaveCount(2); + result.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[2]); + result.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[3]); +} + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldUpdateExistingPersonPercentage() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 50m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 50m } + } + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 70m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 30m } + }; + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().HaveCount(2); + var person1 = title.TitlePeople.First(x => x.PersonId == TestUtils.Guids[1]); + var person2 = title.TitlePeople.First(x => x.PersonId == TestUtils.Guids[2]); + person1.Percentage.Should().Be(70m); + person2.Percentage.Should().Be(30m); + + result.Should().HaveCount(0); +} + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldRemoveAllPeople() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 50m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 50m } + } + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput>(); + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().BeEmpty(); + result.Should().HaveCount(2); + result.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[1]); + result.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[2]); +} + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldAddAndRemoveSimultaneously() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 50m }, + new() { PersonId = TestUtils.Guids[2], Percentage = 50m } + } + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 60m }, // Keep and update + new() { PersonId = TestUtils.Guids[3], Percentage = 40m } // Add new + // Remove TestUtils.Guids[2] + }; + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().HaveCount(2); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[1]); + title.TitlePeople.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[3]); + title.TitlePeople.First(x => x.PersonId == TestUtils.Guids[1]).Percentage.Should().Be(60m); + title.TitlePeople.First(x => x.PersonId == TestUtils.Guids[3]).Percentage.Should().Be(40m); + + result.Should().HaveCount(1); + result.Select(x => x.PersonId).Should().Contain(TestUtils.Guids[2]); +} + +[Fact] +public void SyncPeopleAndReturnToRemove_ShouldHandleEmptyInitialList() +{ + // Arrange + var initialInput = new TitleInput + { + Value = 100m, + Type = TitleType.Income, + Description = "Test", + Date = DateTime.Now, + WalletId = TestUtils.Guids[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput>() + }; + var title = new Title(initialInput, 0); + + var updatePeople = new List<TitlePersonInput> + { + new() { PersonId = TestUtils.Guids[1], Percentage = 100m } + }; + + // Act + var result = title.SyncPeopleAndReturnToRemove(updatePeople); + + // Assert + title.TitlePeople.Should().HaveCount(1); + title.TitlePeople.First().PersonId.Should().Be(TestUtils.Guids[1]); + title.TitlePeople.First().Percentage.Should().Be(100m); + result.Should().BeEmpty(); +} + +#endregion #region MustReprocess diff --git a/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs b/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs index 11e194d..ea99dad 100644 --- a/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs +++ b/Fin.Test/Titles/TitleUpdateHelpServiceTest.cs @@ -1,5 +1,7 @@ using Fin.Application.Titles.Services; using Fin.Application.Wallets.Services; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Titles.Dtos; using Fin.Domain.Titles.Entities; @@ -43,7 +45,6 @@ public async Task UpdateTitleAndCategories_ShouldUpdateTitleAndRemoveCategories( var titleCategory1 = TestUtils.TitleCategories[0]; var titleCategory2 = TestUtils.TitleCategories[1]; - var titleCategory3 = TestUtils.TitleCategories[2]; await resources.TitleCategoryRepository.AddAsync(titleCategory1, autoSave: true); await resources.TitleCategoryRepository.AddAsync(titleCategory2, autoSave: true); @@ -58,6 +59,7 @@ public async Task UpdateTitleAndCategories_ShouldUpdateTitleAndRemoveCategories( TitleCategoriesIds = new List<Guid> { titleCategory1.Id, titleCategory2.Id } }, 1000m); await resources.TitleRepository.AddAsync(title, autoSave: true); + var previousBalance = title.PreviousBalance; // Create categories to remove var categoriesToRemove = title.TitleTitleCategories.Take(1).ToList(); @@ -72,10 +74,19 @@ public async Task UpdateTitleAndCategories_ShouldUpdateTitleAndRemoveCategories( TitleCategoriesIds = new List<Guid> { titleCategory2.Id } }; - title.UpdateAndReturnCategoriesToRemove(updateInput, 1000m); + title.Update(updateInput, 1000m); + title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); + + var udpateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + previousBalance, + categoriesToRemove, + new List<TitlePerson>() + ); // Act - await service.UpdateTitleAndCategories(title, updateInput, categoriesToRemove, CancellationToken.None); + await service.PerformUpdateTitle(title, udpateContext, CancellationToken.None); await Context.SaveChangesAsync(); // Assert @@ -192,7 +203,7 @@ public async Task PrepareUpdateContext_ShouldNotRecalculateBalance_WhenMustNotRe // Assert context.Should().NotBeNull(); context.PreviousBalance.Should().Be(title.PreviousBalance); - + // Verify GetBalanceAt was NOT called _balanceServiceMock.Verify( b => b.GetBalanceAt(It.IsAny<Guid>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()), @@ -540,7 +551,7 @@ public async Task GetTitlesForReprocessing_ShouldFilterByWallet() wallet1.Titles.Add(title1); wallet1.Titles.Add(title2); wallet2.Titles.Add(title3); - + await resources.WalletRepository.AddRangeAsync([wallet1, wallet2], autoSave: true); // Act @@ -600,7 +611,8 @@ public async Task ReprocessAffectedWallets_ShouldReprocessBothWallets_WhenWallet PreviousWalletId: wallet1.Id, // Was in wallet1 PreviousDate: TestUtils.UtcDateTimes[0], PreviousBalance: 1000m, - CategoriesToRemove: new List<TitleTitleCategory>() + CategoriesToRemove: new List<TitleTitleCategory>(), + PeopleToRemove: new List<TitlePerson>() ); _balanceServiceMock @@ -657,7 +669,8 @@ public async Task ReprocessAffectedWallets_ShouldReprocessOnlyCurrentWallet_When PreviousWalletId: wallet.Id, // Same wallet PreviousDate: TestUtils.UtcDateTimes[0], PreviousBalance: 1000m, - CategoriesToRemove: new List<TitleTitleCategory>() + CategoriesToRemove: new List<TitleTitleCategory>(), + PeopleToRemove: new List<TitlePerson>() ); _balanceServiceMock @@ -684,11 +697,533 @@ public async Task ReprocessAffectedWallets_ShouldReprocessOnlyCurrentWallet_When #endregion + #region UpdateTitleAndPeople + + [Fact] + public async Task UpdateTitleAndPeople_ShouldUpdateTitleAndRemovePeople() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[4] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + await resources.PersonRepository.AddAsync(person2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m }, + new() { PersonId = person2.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + var previousBalance = title.PreviousBalance; + + // Create people to remove + var peopleToRemove = title.TitlePeople.Take(1).ToList(); + + var updateInput = new TitleInput + { + Description = "Updated Description", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person2.Id, Percentage = 100m } + } + }; + + title.Update(updateInput, 1000m); + title.SyncPeopleAndReturnToRemove(updateInput.TitlePeople); + + var updateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + previousBalance, + new List<TitleTitleCategory>(), + peopleToRemove + ); + + // Act + await service.PerformUpdateTitle(title, updateContext, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.Description.Should().Be("Updated Description"); + updatedTitle.Value.Should().Be(600m); + updatedTitle.TitlePeople.Should().HaveCount(1); + updatedTitle.TitlePeople.First().PersonId.Should().Be(person2.Id); + updatedTitle.TitlePeople.First().Percentage.Should().Be(100m); + } + + [Fact] + public async Task UpdateTitleAndPeople_ShouldUpdateTitleAndAddPeople() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[4] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + await resources.PersonRepository.AddAsync(person2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 100m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + var previousBalance = title.PreviousBalance; + + var updateInput = new TitleInput + { + Description = "Updated Description", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m }, + new() { PersonId = person2.Id, Percentage = 50m } + } + }; + + title.Update(updateInput, 1000m); + var peopleToRemove = title.SyncPeopleAndReturnToRemove(updateInput.TitlePeople); + + var updateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + previousBalance, + new List<TitleTitleCategory>(), + peopleToRemove + ); + + // Act + await service.PerformUpdateTitle(title, updateContext, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.TitlePeople.Should().HaveCount(2); + updatedTitle.TitlePeople.Select(tp => tp.PersonId).Should().Contain(person1.Id); + updatedTitle.TitlePeople.Select(tp => tp.PersonId).Should().Contain(person2.Id); + } + + [Fact] + public async Task UpdateTitleAndPeople_ShouldUpdatePersonPercentage() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var updateInput = new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 75m } + } + }; + + title.Update(updateInput, 1000m); + var peopleToRemove = title.SyncPeopleAndReturnToRemove(updateInput.TitlePeople); + + var updateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + 1000m, + new List<TitleTitleCategory>(), + peopleToRemove + ); + + // Act + await service.PerformUpdateTitle(title, updateContext, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.TitlePeople.Should().HaveCount(1); + updatedTitle.TitlePeople.First().PersonId.Should().Be(person1.Id); + updatedTitle.TitlePeople.First().Percentage.Should().Be(75m); + } + + [Fact] + public async Task UpdateTitleAndPeople_ShouldRemoveAllPeople() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[4] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + await resources.PersonRepository.AddAsync(person2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m }, + new() { PersonId = person2.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var updateInput = new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput>() + }; + + title.Update(updateInput, 1000m); + var peopleToRemove = title.SyncPeopleAndReturnToRemove(updateInput.TitlePeople); + + var updateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + 1000m, + new List<TitleTitleCategory>(), + peopleToRemove + ); + + // Act + await service.PerformUpdateTitle(title, updateContext, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.TitlePeople.Should().BeEmpty(); + } + + [Fact] + public async Task UpdateTitleAndPeople_ShouldHandleCategoriesAndPeopleSimultaneously() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var category1 = TestUtils.TitleCategories[0]; + var category2 = TestUtils.TitleCategories[1]; + await resources.TitleCategoryRepository.AddAsync(category1, autoSave: true); + await resources.TitleCategoryRepository.AddAsync(category2, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[4] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + await resources.PersonRepository.AddAsync(person2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid> { category1.Id, category2.Id }, + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m }, + new() { PersonId = person2.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var updateInput = new TitleInput + { + Description = "Updated", + Value = 600m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[1], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid> { category2.Id }, + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 100m } + } + }; + + title.Update(updateInput, 1000m); + var categoriesToRemove = title.SyncCategoriesAndReturnToRemove(updateInput.TitleCategoriesIds); + var peopleToRemove = title.SyncPeopleAndReturnToRemove(updateInput.TitlePeople); + + var updateContext = new UpdateTitleContext( + wallet.Id, + TestUtils.UtcDateTimes[0], + 1000m, + categoriesToRemove, + peopleToRemove + ); + + // Act + await service.PerformUpdateTitle(title, updateContext, CancellationToken.None); + await Context.SaveChangesAsync(); + + // Assert + var updatedTitle = await resources.TitleRepository.AsNoTracking() + .Include(t => t.TitleTitleCategories) + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == title.Id); + + updatedTitle.Description.Should().Be("Updated"); + updatedTitle.Value.Should().Be(600m); + updatedTitle.TitleTitleCategories.Should().HaveCount(1); + updatedTitle.TitleTitleCategories.First().TitleCategoryId.Should().Be(category2.Id); + updatedTitle.TitlePeople.Should().HaveCount(1); + updatedTitle.TitlePeople.First().PersonId.Should().Be(person1.Id); + updatedTitle.TitlePeople.First().Percentage.Should().Be(100m); + } + + #endregion + + #region PrepareUpdateContext - People + + [Fact] + public async Task PrepareUpdateContext_ShouldIncludePeopleToRemove() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + var person2 = new Person(new PersonInput { Name = TestUtils.Strings[4] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + await resources.PersonRepository.AddAsync(person2, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m }, + new() { PersonId = person2.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 100m } + } + }; + + // Act + var context = await service.PrepareUpdateContext(title, input, mustReprocess: false, CancellationToken.None); + + // Assert + context.Should().NotBeNull(); + context.PeopleToRemove.Should().HaveCount(1); + context.CategoriesToRemove.Should().BeEmpty(); + } + + [Fact] + public async Task PrepareUpdateContext_ShouldReturnEmptyPeopleList_WhenNoPeopleRemoved() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var wallet = new Wallet(new WalletInput + { + Name = TestUtils.Strings[0], + Color = TestUtils.Strings[1], + Icon = TestUtils.Strings[2], + InitialBalance = 1000m + }); + await resources.WalletRepository.AddAsync(wallet, autoSave: true); + + var person1 = new Person(new PersonInput { Name = TestUtils.Strings[3] }); + await resources.PersonRepository.AddAsync(person1, autoSave: true); + + var title = new Title(new TitleInput + { + Description = TestUtils.Strings[5], + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 50m } + } + }, 1000m); + await resources.TitleRepository.AddAsync(title, autoSave: true); + + var input = new TitleInput + { + Description = "Updated", + Value = 500m, + Type = TitleType.Income, + Date = TestUtils.UtcDateTimes[0], + WalletId = wallet.Id, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput> + { + new() { PersonId = person1.Id, Percentage = 75m } // Same person, different percentage + } + }; + + // Act + var context = await service.PrepareUpdateContext(title, input, mustReprocess: false, CancellationToken.None); + + // Assert + context.Should().NotBeNull(); + context.PeopleToRemove.Should().HaveCount(0); + } + + #endregion + private TitleUpdateHelpService GetService(Resources resources) { return new TitleUpdateHelpService( resources.TitleRepository, resources.TitleTitleCategoryRepository, + resources.TitlePersonsRepository, _balanceServiceMock.Object ); } @@ -700,7 +1235,9 @@ private Resources GetResources() TitleRepository = GetRepository<Title>(), TitleTitleCategoryRepository = GetRepository<TitleTitleCategory>(), TitleCategoryRepository = GetRepository<TitleCategory>(), - WalletRepository = GetRepository<Wallet>() + WalletRepository = GetRepository<Wallet>(), + TitlePersonsRepository = GetRepository<TitlePerson>(), + PersonRepository = GetRepository<Person>() }; } @@ -709,6 +1246,8 @@ private class Resources public IRepository<Title> TitleRepository { get; set; } public IRepository<TitleTitleCategory> TitleTitleCategoryRepository { get; set; } public IRepository<TitleCategory> TitleCategoryRepository { get; set; } + public IRepository<TitlePerson> TitlePersonsRepository { get; set; } public IRepository<Wallet> WalletRepository { get; set; } + public IRepository<Person> PersonRepository { get; set; } } } \ No newline at end of file diff --git a/Fin.Test/Titles/Validations/TitleInputPeopleValidationTest.cs b/Fin.Test/Titles/Validations/TitleInputPeopleValidationTest.cs new file mode 100644 index 0000000..9871b18 --- /dev/null +++ b/Fin.Test/Titles/Validations/TitleInputPeopleValidationTest.cs @@ -0,0 +1,492 @@ +using Fin.Application.Titles.Enums; +using Fin.Application.Titles.Validations.UpdateOrCrestes; +using Fin.Domain.People.Dtos; +using Fin.Domain.People.Entities; +using Fin.Domain.Titles.Dtos; +using Fin.Domain.Titles.Entities; +using Fin.Domain.Titles.Enums; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Test.Titles.Validations; + +public class TitleInputPeopleValidationTest : TestUtils.BaseTestWithContext +{ + private TitleInput GetValidInput() => new() + { + Description = TestUtils.Strings[0], + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + WalletId = TestUtils.Guids[0], + Type = TitleType.Income, + TitleCategoriesIds = new List<Guid>(), + TitlePeople = new List<TitlePersonInput>() + }; + + private async Task<Person> CreatePersonInDatabase( + Resources resources, + Guid id, + string name, + bool inactivated = false) + { + var person = new Person(new PersonInput + { + Name = name + }); + + person.Id = id; + + if (inactivated != person.Inactivated) + person.ToggleInactivated(); + + await resources.PersonRepository.AddAsync(person, autoSave: true); + return person; + } + + private async Task<Title> CreateTitleInDatabase( + Resources resources, + Guid id, + List<Person> people) + { + var input = new TitleInput + { + Value = TestUtils.Decimals[0], + Date = TestUtils.UtcDateTimes[0], + Type = TitleType.Income, + Description = TestUtils.Strings[0], + TitleCategoriesIds = new List<Guid>(), + TitlePeople = people.Select(p => new TitlePersonInput + { + PersonId = p.Id, + Percentage = 50m + }).ToList() + }; + + var title = new Title(input, 0m); + title.Wallet = TestUtils.Wallets[0]; + title.Id = id; + await resources.TitleRepository.AddAsync(title, autoSave: true); + + // Eager load TitlePeople for verification + return await resources.TitleRepository + .Include(t => t.TitlePeople) + .FirstAsync(t => t.Id == id); + } + + #region ValidateAsync + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenPeopleAreValid() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 60m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 40m }); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + result.Data.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenNoPeople() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var input = GetValidInput(); + // TitlePeople is empty + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenSplitIs100Percent() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 50m }); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSomePeopleNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var notFoundId = TestUtils.Guids[9]; + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = notFoundId, Percentage = 50m }); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomePeopleNotFound); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { notFoundId }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenMultiplePeopleNotFound() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var notFoundId1 = TestUtils.Guids[8]; + var notFoundId2 = TestUtils.Guids[9]; + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = notFoundId1, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = notFoundId2, Percentage = 50m }); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomePeopleNotFound); + result.Data.Should().HaveCount(2); + result.Data.Should().BeEquivalentTo(new List<Guid> { notFoundId1, notFoundId2 }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSomePeopleInactiveOnCreate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2], inactivated: true); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2_Inactive.Id, Percentage = 50m }); + + // Act + var result = await service.ValidateAsync(input, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomePeopleInactive); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { person2_Inactive.Id }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenPersonIsInactiveButAlreadyOnTitle() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1], inactivated: true); + var title = await CreateTitleInDatabase(resources, TestUtils.Guids[5], new List<Person> { person1_Inactive }); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1_Inactive.Id, Percentage = 100m }); + + // Act + var result = await service.ValidateAsync(input, editingId: title.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenAddingNewInactivePersonOnUpdate() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1_Active = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2], inactivated: true); + var title = await CreateTitleInDatabase(resources, TestUtils.Guids[5], new List<Person> { person1_Active }); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1_Active.Id, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2_Inactive.Id, Percentage = 50m }); // Adding a new inactive person + + // Act + var result = await service.ValidateAsync(input, editingId: title.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomePeopleInactive); + result.Data.Should().HaveCount(1); + result.Data.Should().BeEquivalentTo(new List<Guid> { person2_Inactive.Id }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSplitExceeds100Percent() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 60m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 50m }); // Total: 110% + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.PeopleSplitRange); + result.Data.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSplitIsNegative() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = -60m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 50m }); // Total: 10% (but has negative) + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.PeopleSplitRange); + result.Data.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenSplitIsLessThan0Dot01() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 0m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 0.009m }); // Total: 10% (but has negative) + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.PeopleSplitRange); + result.Data.Should().BeNull(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenSplitIs0Dot01() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 0m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 0.01m }); // Total: 10% (but has negative) + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Code.Should().BeNull(); + result.Data.Should().BeNull(); + } + + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenSplitIsLessThan100Percent() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + var person2 = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 30m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2.Id, Percentage = 40m }); // Total: 70% + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenOnlyOnePersonWith100Percent() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1 = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1]); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1.Id, Percentage = 100m }); + + // Act + var result = await service.ValidateAsync(input); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnFailure_WhenMultipleInactivePeople() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1], inactivated: true); + var person2_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2], inactivated: true); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1_Inactive.Id, Percentage = 50m }); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2_Inactive.Id, Percentage = 50m }); + + // Act + var result = await service.ValidateAsync(input, editingId: null); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.Code.Should().Be(TitleCreateOrUpdateErrorCode.SomePeopleInactive); + result.Data.Should().HaveCount(2); + result.Data.Should().BeEquivalentTo(new List<Guid> { person1_Inactive.Id, person2_Inactive.Id }); + } + + [Fact] + public async Task ValidateAsync_ShouldReturnSuccess_WhenKeepingInactivePeopleAndAddingActiveOnes() + { + // Arrange + await ConfigureLoggedAmbientAsync(); + var resources = GetResources(); + var service = GetService(resources); + + var person1_Inactive = await CreatePersonInDatabase(resources, TestUtils.Guids[1], TestUtils.Strings[1], inactivated: true); + var person2_Active = await CreatePersonInDatabase(resources, TestUtils.Guids[2], TestUtils.Strings[2]); + var title = await CreateTitleInDatabase(resources, TestUtils.Guids[5], new List<Person> { person1_Inactive }); + + var input = GetValidInput(); + input.TitlePeople.Add(new TitlePersonInput { PersonId = person1_Inactive.Id, Percentage = 50m }); // Already on title + input.TitlePeople.Add(new TitlePersonInput { PersonId = person2_Active.Id, Percentage = 50m }); // New active person + + // Act + var result = await service.ValidateAsync(input, editingId: title.Id); + + // Assert + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + #endregion + + private TitleInputPeopleValidation GetService(Resources resources) + { + return new TitleInputPeopleValidation( + resources.TitleRepository, + resources.PersonRepository + ); + } + + private Resources GetResources() + { + return new Resources + { + TitleRepository = GetRepository<Title>(), + PersonRepository = GetRepository<Person>() + }; + } + + private class Resources + { + public IRepository<Title> TitleRepository { get; set; } + public IRepository<Person> PersonRepository { get; set; } + } +} \ No newline at end of file