diff --git a/.idea/.idea.Fin-Backend/.idea/dataSources.xml b/.idea/.idea.Fin-Backend/.idea/dataSources.xml index 9ed83d1..ff0e1a0 100644 --- a/.idea/.idea.Fin-Backend/.idea/dataSources.xml +++ b/.idea/.idea.Fin-Backend/.idea/dataSources.xml @@ -4,6 +4,7 @@ postgresql true + true org.postgresql.Driver jdbc:postgresql://localhost:5432/postgres @@ -13,5 +14,12 @@ $ProjectFileDir$ + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + \ No newline at end of file diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index ca2fb04..6ef6c84 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -4,6 +4,9 @@ ForceIncluded ForceIncluded ForceIncluded + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> diff --git a/Fin.Api/CreditCards/CreditCardController.cs b/Fin.Api/CreditCards/CreditCardController.cs new file mode 100644 index 0000000..3244aac --- /dev/null +++ b/Fin.Api/CreditCards/CreditCardController.cs @@ -0,0 +1,63 @@ +using Fin.Application.CreditCards.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Application.CreditCards.Services; +using Fin.Domain.Global.Classes; +using Fin.Domain.CreditCards.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fin.Api.CreditCards; + +[Route("credit-cards")] +[Authorize] +public class CreditCardController(ICreditCardService service) : ControllerBase +{ + [HttpGet] + public async Task> GetList([FromQuery] CreditCardGetListInput 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] CreditCardInput 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] CreditCardInput input) + { + var validationResult = await service.Update(id, input, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == CreditCardCreateOrUpdateErrorCode.CreditCardNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } + + [HttpPut("toggle-inactivated/{id:guid}")] + public async Task ToggleInactivated([FromRoute] Guid id) + { + var validationResult = await service.ToggleInactive(id, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == CreditCardToggleInactiveErrorCode.CreditCardNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } + + [HttpDelete("{id:guid}")] + public async Task Delete([FromRoute] Guid id) + { + var validationResult = await service.Delete(id, autoSave: true); + return validationResult.Success ? Ok() : + validationResult.ErrorCode == CreditCardDeleteErrorCode.CreditCardNotFound ? NotFound(validationResult) : + UnprocessableEntity(validationResult); + } +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Dtos/CreditCardGetListInput.cs b/Fin.Application/CreditCards/Dtos/CreditCardGetListInput.cs new file mode 100644 index 0000000..e050beb --- /dev/null +++ b/Fin.Application/CreditCards/Dtos/CreditCardGetListInput.cs @@ -0,0 +1,11 @@ +using Fin.Domain.Global.Classes; + +namespace Fin.Application.CreditCards.Dtos; + +public class CreditCardGetListInput: PagedFilteredAndSortedInput +{ + public bool? Inactivated { get; set; } + public List DebitWalletIds { get; set; } = []; + public List FinancialInstitutionIds { get; set; } = []; + public List CardBrandIds { get; set; } = []; +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Enums/CreditCardCreateOrUpdateErrorCode.cs b/Fin.Application/CreditCards/Enums/CreditCardCreateOrUpdateErrorCode.cs new file mode 100644 index 0000000..dbd1cc4 --- /dev/null +++ b/Fin.Application/CreditCards/Enums/CreditCardCreateOrUpdateErrorCode.cs @@ -0,0 +1,25 @@ +namespace Fin.Application.CreditCards.Enums; + +public enum CreditCardCreateOrUpdateErrorCode +{ + NameIsRequired = 0, + NameAlreadyInUse = 1, + NameTooLong = 2, + ColorIsRequired = 3, + ColorTooLong = 4, + IconIsRequired = 5, + IconTooLong = 6, + CreditCardNotFound = 7, + + FinancialInstitutionNotFound = 8, + FinancialInstitutionInactivated = 9, + + DebitWalletNotFound = 10, + DebitWalletInactivated = 11, + + CardBrandNotFound = 12, + + LimitMinValueZero = 13, + DueDayOutOfRange = 14, + ClosingDayOutOfRange = 15 +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Enums/CreditCardDeleteErrorCode.cs b/Fin.Application/CreditCards/Enums/CreditCardDeleteErrorCode.cs new file mode 100644 index 0000000..12feeb3 --- /dev/null +++ b/Fin.Application/CreditCards/Enums/CreditCardDeleteErrorCode.cs @@ -0,0 +1,7 @@ +namespace Fin.Application.CreditCards.Enums; + +public enum CreditCardDeleteErrorCode +{ + CreditCardNotFound = 0, + CreditCardInUse = 1 +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Enums/CreditCardToggleInactiveErrorCode.cs b/Fin.Application/CreditCards/Enums/CreditCardToggleInactiveErrorCode.cs new file mode 100644 index 0000000..09bfacf --- /dev/null +++ b/Fin.Application/CreditCards/Enums/CreditCardToggleInactiveErrorCode.cs @@ -0,0 +1,6 @@ +namespace Fin.Application.CreditCards.Enums; + +public enum CreditCardToggleInactiveErrorCode +{ + CreditCardNotFound = 0 +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Services/CreditCardService.cs b/Fin.Application/CreditCards/Services/CreditCardService.cs new file mode 100644 index 0000000..b3b185e --- /dev/null +++ b/Fin.Application/CreditCards/Services/CreditCardService.cs @@ -0,0 +1,97 @@ +using Fin.Application.Globals.Dtos; +using Fin.Application.CreditCards.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Domain.Global.Classes; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.CreditCards.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Extensions; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.CreditCards.Services; + +public interface ICreditCardService +{ + public Task Get(Guid id); + public Task> GetList(CreditCardGetListInput input); + public Task> Create(CreditCardInput input, bool autoSave = false); + public Task> Update(Guid id, CreditCardInput input, bool autoSave = false); + public Task> Delete(Guid id, bool autoSave = false); + public Task> ToggleInactive(Guid id, bool autoSave = false); +} + +public class CreditCardService( + IRepository repository, + ICreditCardValidationService validationService + ) : ICreditCardService, IAutoTransient +{ + public async Task Get(Guid id) + { + var entity = await repository.Query(false).FirstOrDefaultAsync(n => n.Id == id); + return entity != null ? new CreditCardOutput(entity) : null; + } + + public async Task> GetList(CreditCardGetListInput input) + { + return await repository.Query(false) + .WhereIf(input.Inactivated.HasValue, n => n.Inactivated == input.Inactivated.Value) + .WhereIf(input.CardBrandIds.Any(), creditCard => input.CardBrandIds.Contains(creditCard.CardBrandId)) + .WhereIf(input.DebitWalletIds.Any(), creditCard => input.DebitWalletIds.Contains(creditCard.DebitWalletId)) + .WhereIf(input.FinancialInstitutionIds.Any(), creditCard => input.FinancialInstitutionIds.Contains(creditCard.FinancialInstitutionId)) + .OrderBy(m => m.Inactivated) + .ThenBy(m => m.Name) + .ApplyFilterAndSorter(input) + .Select(n => new CreditCardOutput(n)) + .ToPagedResult(input); + } + + public async Task> Create(CreditCardInput input, bool autoSave = false) + { + var validation = await validationService.ValidateInput(input); + if (!validation.Success) return validation; + + var creditCard = new CreditCard(input); + await repository.AddAsync(creditCard, autoSave); + validation.Data = new CreditCardOutput(creditCard); + return validation; + } + + public async Task> Update(Guid id, CreditCardInput input, bool autoSave = false) + { + var validation = await validationService.ValidateInput(input, id); + if (!validation.Success) return validation; + + var creditCard = await repository.Query().FirstAsync(u => u.Id == id); + creditCard.Update(input); + await repository.UpdateAsync(creditCard, autoSave); + + validation.Data = true; + return validation; + } + + public async Task> Delete(Guid id, bool autoSave = false) + { + var validation = await validationService.ValidateDelete(id); + if (!validation.Success) return validation; + + var creditCard = await repository.Query().FirstAsync(u => u.Id == id); + await repository.DeleteAsync(creditCard, autoSave); + + validation.Success = true; + return validation; + } + + public async Task> ToggleInactive(Guid id, bool autoSave = false) + { + var validation = await validationService.ValidateToggleInactive(id); + if (!validation.Success) return validation; + + var creditCard = await repository.Query().FirstAsync(u => u.Id == id); + creditCard.ToggleInactivated(); + await repository.UpdateAsync(creditCard, autoSave); + + validation.Data = true; + return validation; + } +} \ No newline at end of file diff --git a/Fin.Application/CreditCards/Services/CreditCardValidationService.cs b/Fin.Application/CreditCards/Services/CreditCardValidationService.cs new file mode 100644 index 0000000..abbaed3 --- /dev/null +++ b/Fin.Application/CreditCards/Services/CreditCardValidationService.cs @@ -0,0 +1,190 @@ +using Fin.Application.CardBrands; +using Fin.Application.FinancialInstitutions; +using Fin.Application.Globals.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Application.Wallets.Services; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.CreditCards.Entities; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.CreditCards.Services; + +public interface ICreditCardValidationService +{ + public Task> ValidateToggleInactive(Guid creditCardId); + public Task> ValidateDelete(Guid creditCardId); + + public Task> ValidateInput(CreditCardInput input, + Guid? editingId = null); +} + +public class CreditCardValidationService( + IRepository repository, + IFinancialInstitutionService financialInstitutionService, + IWalletService walletService, + ICardBrandService cardBrandService +) : ICreditCardValidationService, IAutoTransient +{ + public async Task> ValidateToggleInactive(Guid creditCardId) + { + var validationResult = new ValidationResultDto(); + + var creditCard = await repository.Query(tracking: false).FirstOrDefaultAsync(n => n.Id == creditCardId); + if (creditCard is null) + { + validationResult.ErrorCode = CreditCardToggleInactiveErrorCode.CreditCardNotFound; + validationResult.Message = "CreditCard not found to toggle inactive."; + return validationResult; + } + + validationResult.Success = true; + return validationResult; + } + + public async Task> ValidateDelete(Guid creditCardId) + { + var validationResult = new ValidationResultDto(); + + var creditCardExists = await repository.Query().AnyAsync(n => n.Id == creditCardId); + if (!creditCardExists) + { + validationResult.ErrorCode = CreditCardDeleteErrorCode.CreditCardNotFound; + validationResult.Message = "CreditCard not found to delete."; + return validationResult; + } + + // TODO here validate relations + + validationResult.Success = true; + return validationResult; + } + + public async Task> ValidateInput(CreditCardInput input, + Guid? editingId = null) + { + var validationResult = new ValidationResultDto(); + + if (editingId.HasValue) + { + var creditCardExists = await repository.Query() + .AnyAsync(n => n.Id == editingId.Value); + if (!creditCardExists) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.CreditCardNotFound; + validationResult.Message = "CreditCard not found to edit."; + return validationResult; + } + } + + if (string.IsNullOrWhiteSpace(input.Color)) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.ColorIsRequired; + validationResult.Message = "Color is required."; + return validationResult; + } + + if (input.Color.Length > 20) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.ColorTooLong; + validationResult.Message = "Color is too long. Max 20 characters."; + return validationResult; + } + + if (string.IsNullOrWhiteSpace(input.Icon)) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.IconIsRequired; + validationResult.Message = "Icon is required."; + return validationResult; + } + + if (input.Icon.Length > 20) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.IconTooLong; + validationResult.Message = "Icon is too long. Max 20 characters."; + return validationResult; + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.NameIsRequired; + validationResult.Message = "Name is required."; + return validationResult; + } + + if (input.Name.Length > 100) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.NameTooLong; + validationResult.Message = "Name is too long. Max 100 characters."; + return validationResult; + } + + var nameAlredInUse = await repository.Query() + .AnyAsync(n => n.Name.ToLower() == input.Name.ToLower() && (!editingId.HasValue || n.Id != editingId)); + if (nameAlredInUse) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.NameAlreadyInUse; + validationResult.Message = "Name is already in use."; + return validationResult; + } + + var financialInstitution = await financialInstitutionService.Get(input.FinancialInstitutionId); + if (financialInstitution is null) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.FinancialInstitutionNotFound; + validationResult.Message = "Financial institution not found."; + return validationResult; + } + if (financialInstitution.Inactive) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.FinancialInstitutionInactivated; + validationResult.Message = "Financial institution is inactive."; + return validationResult; + } + + var wallet = await walletService.Get(input.DebitWalletId); + if (wallet is null) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.DebitWalletNotFound; + validationResult.Message = "Debit wallet not found."; + return validationResult; + } + if (wallet.Inactivated) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.DebitWalletInactivated; + validationResult.Message = "Debit wallet is inactive."; + return validationResult; + } + + var cardBrand = await cardBrandService.Get(input.CardBrandId); + if (cardBrand is null) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.CardBrandNotFound; + validationResult.Message = "CardBrand not found."; + return validationResult; + } + + if (input.Limit <= 0) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.LimitMinValueZero; + validationResult.Message = "Limit must be greater than zero."; + return validationResult; + } + if (1 > input.DueDay || input.DueDay > 31) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.DueDayOutOfRange; + validationResult.Message = "Due day is out of range. >= 1 and <= 31"; + return validationResult; + } + if (1 > input.ClosingDay || input.ClosingDay > 31) + { + validationResult.ErrorCode = CreditCardCreateOrUpdateErrorCode.ClosingDayOutOfRange; + validationResult.Message = "Closing day is out of range. >= 1 and <= 31"; + return validationResult; + } + + validationResult.Success = true; + return validationResult; + } +} \ No newline at end of file diff --git a/Fin.Application/Wallets/Services/WalletValidationService.cs b/Fin.Application/Wallets/Services/WalletValidationService.cs index 1e44eec..c2a9e6e 100644 --- a/Fin.Application/Wallets/Services/WalletValidationService.cs +++ b/Fin.Application/Wallets/Services/WalletValidationService.cs @@ -1,6 +1,7 @@ using Fin.Application.FinancialInstitutions; using Fin.Application.Globals.Dtos; using Fin.Application.Wallets.Enums; +using Fin.Domain.CreditCards.Entities; using Fin.Domain.Wallets.Dtos; using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AutoServices.Interfaces; @@ -19,7 +20,8 @@ public Task> ValidateInput } public class WalletValidationService( - IRepository repository, + IRepository walletRepository, + IRepository creditCardRepository, IFinancialInstitutionService financialInstitutionService ) : IWalletValidationService, IAutoTransient { @@ -27,15 +29,21 @@ public async Task> Vali { var validationResult = new ValidationResultDto(); - var wallet = await repository.Query(tracking: false).FirstOrDefaultAsync(n => n.Id == walletId); + var wallet = await walletRepository.Query(tracking: false).FirstOrDefaultAsync(n => n.Id == walletId); if (wallet is null) { validationResult.ErrorCode = WalletToggleInactiveErrorCode.WalletNotFound; - validationResult.Message = "Wallet not found to toogle inactive."; + validationResult.Message = "Wallet not found to toggle inactive."; return validationResult; } - // TODO here validate relations + var walletInUseByActivatedCreditCard = await creditCardRepository.Query().AnyAsync(n => n.DebitWalletId == walletId && !n.Inactivated); + if (walletInUseByActivatedCreditCard) + { + validationResult.ErrorCode = WalletToggleInactiveErrorCode.WalletInUseByActivatedCreditCards; + validationResult.Message = "Wallet in use by activated credit cards."; + return validationResult; + } validationResult.Success = true; return validationResult; @@ -45,13 +53,21 @@ public async Task> ValidateDele { var validationResult = new ValidationResultDto(); - var walletExists = await repository.Query().AnyAsync(n => n.Id == walletId); + var walletExists = await walletRepository.Query().AnyAsync(n => n.Id == walletId); if (!walletExists) { validationResult.ErrorCode = WalletDeleteErrorCode.WalletNotFound; validationResult.Message = "Wallet not found to delete."; return validationResult; } + + var walletInUseByCreditCard = await creditCardRepository.Query().AnyAsync(n => n.DebitWalletId == walletId); + if (walletInUseByCreditCard) + { + validationResult.ErrorCode = WalletDeleteErrorCode.WalletInUseByCreditCards; + validationResult.Message = "Wallet in use by credit cards."; + return validationResult; + } // TODO here validate relations @@ -66,7 +82,7 @@ public async Task> Validat if (editingId.HasValue) { - var walletExists = await repository.Query() + var walletExists = await walletRepository.Query() .AnyAsync(n => n.Id == editingId.Value); if (!walletExists) { @@ -118,7 +134,7 @@ public async Task> Validat return validationResult; } - var nameAlredInUse = await repository.Query() + var nameAlredInUse = await walletRepository.Query() .AnyAsync(n => n.Name.ToLower() == input.Name.ToLower() && (!editingId.HasValue || n.Id != editingId)); if (nameAlredInUse) { diff --git a/Fin.Domain/CardBrands/Entities/CardBrand.cs b/Fin.Domain/CardBrands/Entities/CardBrand.cs index 64e951d..48b8989 100644 --- a/Fin.Domain/CardBrands/Entities/CardBrand.cs +++ b/Fin.Domain/CardBrands/Entities/CardBrand.cs @@ -1,4 +1,5 @@ using Fin.Domain.CardBrands.Dtos; +using Fin.Domain.CreditCards.Entities; using Fin.Domain.Global.Interfaces; namespace Fin.Domain.CardBrands.Entities; @@ -9,6 +10,8 @@ public class CardBrand: IAuditedEntity public string Icon { get; set; } public string Color { get; set; } + public virtual ICollection CreditCards { get; set; } + public Guid Id { get; set; } public Guid CreatedBy { get; set; } public Guid UpdatedBy { get; set; } diff --git a/Fin.Domain/CreditCards/Dtos/CreditCardInput.cs b/Fin.Domain/CreditCards/Dtos/CreditCardInput.cs new file mode 100644 index 0000000..a11251c --- /dev/null +++ b/Fin.Domain/CreditCards/Dtos/CreditCardInput.cs @@ -0,0 +1,14 @@ +namespace Fin.Domain.CreditCards.Dtos; + +public class CreditCardInput +{ + public string Name { get; set; } + public string Color { get; set; } + public string Icon { get; set; } + public decimal Limit { get; set; } + public int DueDay { get; set; } + public int ClosingDay { get; set; } + public Guid DebitWalletId { get; set; } + public Guid CardBrandId { get; set; } + public Guid FinancialInstitutionId { get; set; } +} \ No newline at end of file diff --git a/Fin.Domain/CreditCards/Dtos/CreditCardOutput.cs b/Fin.Domain/CreditCards/Dtos/CreditCardOutput.cs new file mode 100644 index 0000000..a1f2e7b --- /dev/null +++ b/Fin.Domain/CreditCards/Dtos/CreditCardOutput.cs @@ -0,0 +1,22 @@ +using Fin.Domain.CreditCards.Entities; + +namespace Fin.Domain.CreditCards.Dtos; + +public class CreditCardOutput(CreditCard card) +{ + public Guid Id { get; set; } = card.Id; + public string Name { get; set; } = card.Name; + public string Color { get; set; } = card.Color; + public string Icon { get; set; } = card.Icon; + public decimal Limit { get; set; } = card.Limit; + public int DueDay { get; set; } = card.DueDay; + public int ClosingDay { get; set; } = card.ClosingDay; + public Guid DebitWalletId { get; set; } = card.DebitWalletId; + public Guid CardBrandId { get; set; } = card.CardBrandId; + public Guid FinancialInstitutionId { get; set; } = card.FinancialInstitutionId; + public bool Inactivated { get; private set; } = card.Inactivated; + + public CreditCardOutput(): this(new CreditCard()) + { + } +} \ No newline at end of file diff --git a/Fin.Domain/CreditCards/Entities/CreditCard.cs b/Fin.Domain/CreditCards/Entities/CreditCard.cs new file mode 100644 index 0000000..81f94fd --- /dev/null +++ b/Fin.Domain/CreditCards/Entities/CreditCard.cs @@ -0,0 +1,66 @@ +using Fin.Domain.CardBrands.Entities; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.FinancialInstitutions.Entities; +using Fin.Domain.Global.Interfaces; +using Fin.Domain.Wallets.Entities; + +namespace Fin.Domain.CreditCards.Entities; + +public class CreditCard: IAuditedTenantEntity +{ + public string Name { get; private set; } + public string Color { get; private set; } + public string Icon { get; private set; } + public decimal Limit { get; private set; } + public int DueDay { get; private set; } + public int ClosingDay { get; private set; } + public bool Inactivated { get; private set; } + + public Guid DebitWalletId { get; private set; } + public virtual Wallet DebitWallet { get; set; } + + public Guid CardBrandId { get; private set; } + public virtual CardBrand CardBrand { get; set; } + + public Guid FinancialInstitutionId { get; set; } + public virtual FinancialInstitution FinancialInstitution { get; 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 CreditCard() + { + } + + public CreditCard(CreditCardInput input) + { + Name = input.Name; + Color = input.Color; + Icon = input.Icon; + Limit = input.Limit; + DueDay = input.DueDay; + ClosingDay = input.ClosingDay; + DebitWalletId = input.DebitWalletId; + CardBrandId = input.CardBrandId; + FinancialInstitutionId = input.FinancialInstitutionId; + } + + public void Update(CreditCardInput input) + { + Name = input.Name; + Color = input.Color; + Icon = input.Icon; + Limit = input.Limit; + DueDay = input.DueDay; + ClosingDay = input.ClosingDay; + DebitWalletId = input.DebitWalletId; + CardBrandId = input.CardBrandId; + FinancialInstitutionId = input.FinancialInstitutionId; + } + + public void ToggleInactivated() => Inactivated = !Inactivated; +} \ No newline at end of file diff --git a/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs b/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs index 87cbbed..f7d2dd3 100644 --- a/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs +++ b/Fin.Domain/FinancialInstitutions/Entities/FinancialInstitution.cs @@ -1,3 +1,4 @@ +using Fin.Domain.CreditCards.Entities; using Fin.Domain.FinancialInstitutions.Dtos; using Fin.Domain.FinancialInstitutions.Enums; using Fin.Domain.Global.Interfaces; @@ -15,6 +16,7 @@ public class FinancialInstitution : IAuditedEntity public bool Inactive { get; set; } public virtual ICollection Wallets { get; set; } + public virtual ICollection CreditCards { get; set; } public Guid Id { get; set; } public Guid CreatedBy { get; set; } diff --git a/Fin.Domain/Wallets/Dtos/WalletInput.cs b/Fin.Domain/Wallets/Dtos/WalletInput.cs index 76be974..54c6df9 100644 --- a/Fin.Domain/Wallets/Dtos/WalletInput.cs +++ b/Fin.Domain/Wallets/Dtos/WalletInput.cs @@ -4,17 +4,9 @@ namespace Fin.Domain.Wallets.Dtos; public class WalletInput { - [Required] public string Name { get; set; } - - [Required] public string Color { get; set; } - - [Required] public string Icon { get; set; } - public Guid? FinancialInstitutionId { get; set; } - - [Required] public decimal InitialBalance { get; set; } } \ No newline at end of file diff --git a/Fin.Domain/Wallets/Entities/Wallet.cs b/Fin.Domain/Wallets/Entities/Wallet.cs index 9a12f9f..613bdee 100644 --- a/Fin.Domain/Wallets/Entities/Wallet.cs +++ b/Fin.Domain/Wallets/Entities/Wallet.cs @@ -1,3 +1,4 @@ +using Fin.Domain.CreditCards.Entities; using Fin.Domain.FinancialInstitutions.Entities; using Fin.Domain.Global.Interfaces; using Fin.Domain.Wallets.Dtos; @@ -19,10 +20,13 @@ public class Wallet: IAuditedTenantEntity public bool Inactivated { get; private set; } public Guid? FinancialInstitutionId { get; private set; } - public virtual FinancialInstitution FinancialInstitution { get; } + public virtual FinancialInstitution FinancialInstitution { get; set; } public decimal InitialBalance { get; private set; } public decimal CurrentBalance { get; set; } + + + public virtual ICollection CreditCards { get; set; } public Wallet() { diff --git a/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs b/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs new file mode 100644 index 0000000..1ff6919 --- /dev/null +++ b/Fin.Infrastructure/Database/Configurations/CreditCards/CreditCardConfiguration.cs @@ -0,0 +1,45 @@ +using Fin.Domain.CreditCards.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fin.Infrastructure.Database.Configurations.CreditCards; + +public class CreditCardConfiguration: IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).HasMaxLength(100).IsRequired(); + builder.Property(x => x.Icon).HasMaxLength(20).IsRequired(); + builder.Property(x => x.Color).HasMaxLength(20).IsRequired(); + + builder.HasIndex(x => new {x.Name, x.TenantId}).IsUnique(); + + builder + .Property(p => p.Limit) + .HasColumnType("numeric(19,4)") + .HasPrecision(19, 4); + + builder + .HasOne(creditCard => creditCard.FinancialInstitution) + .WithMany(financialInstitution => financialInstitution.CreditCards) + .HasForeignKey(creditCard => creditCard.FinancialInstitutionId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne(creditCard => creditCard.CardBrand) + .WithMany(cardBrand => cardBrand.CreditCards) + .HasForeignKey(creditCard => creditCard.CardBrandId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasOne(creditCard => creditCard.DebitWallet) + .WithMany(debitWallet => debitWallet.CreditCards) + .HasForeignKey(creditCard => creditCard.DebitWalletId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Restrict); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Database/FinDbContext.cs b/Fin.Infrastructure/Database/FinDbContext.cs index 9b268d0..f45101a 100644 --- a/Fin.Infrastructure/Database/FinDbContext.cs +++ b/Fin.Infrastructure/Database/FinDbContext.cs @@ -1,15 +1,14 @@ using System.Reflection; using Fin.Domain.CardBrands.Entities; +using Fin.Domain.CreditCards.Entities; using Fin.Domain.Global.Interfaces; using Fin.Domain.Menus.Entities; -using Fin.Domain.Notifications; using Fin.Domain.Notifications.Entities; using Fin.Domain.Tenants.Entities; using Fin.Domain.TitleCategories.Entities; using Fin.Domain.Users.Entities; using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AmbientDatas; -using Fin.Infrastructure.Database.Configurations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -34,6 +33,7 @@ public class FinDbContext : DbContext public DbSet CardBrands { get; set; } public DbSet TitleCategories { get; set; } public DbSet Wallets { get; set; } + public DbSet CreditCards { get; set; } private readonly IAmbientData _ambientData; diff --git a/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.Designer.cs b/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.Designer.cs new file mode 100644 index 0000000..3fe8a26 --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.Designer.cs @@ -0,0 +1,831 @@ +// +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("20251011180701_adding_creditcard")] + partial class adding_creditcard + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("CardBrands", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CardBrandId") + .HasColumnType("uuid"); + + b.Property("ClosingDay") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DebitWalletId") + .HasColumnType("uuid"); + + b.Property("DueDay") + .HasColumnType("integer"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Continuous") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("HtmlBody") + .HasColumnType("text"); + + b.Property("Link") + .HasColumnType("text"); + + b.Property("NormalizedTextBody") + .HasColumnType("text"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("TextBody") + .HasColumnType("text"); + + b.Property("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Delivery") + .HasColumnType("boolean"); + + b.Property("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedWays") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FirebaseTokens") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NotifyOn") + .HasColumnType("interval"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.Property("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Timezone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActivity") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aborted") + .HasColumnType("boolean"); + + b.Property("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserAbortedId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CurrentBalance") + .HasColumnType("numeric"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + 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.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.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.Users.Entities.User", b => + { + b.Navigation("Credential"); + + b.Navigation("DeleteRequests"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.cs b/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.cs new file mode 100644 index 0000000..8a61d02 --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251011180701_adding_creditcard.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// + public partial class adding_creditcard : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CreditCards", + schema: "public", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Color = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Icon = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Limit = table.Column(type: "numeric(19,4)", precision: 19, scale: 4, nullable: false), + DueDay = table.Column(type: "integer", nullable: false), + ClosingDay = table.Column(type: "integer", nullable: false), + Inactivated = table.Column(type: "boolean", nullable: false), + DebitWalletId = table.Column(type: "uuid", nullable: false), + CardBrandId = table.Column(type: "uuid", nullable: false), + FinancialInstitutionId = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CreditCards", x => x.Id); + table.ForeignKey( + name: "FK_CreditCards_CardBrands_CardBrandId", + column: x => x.CardBrandId, + principalSchema: "public", + principalTable: "CardBrands", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CreditCards_FinancialInstitution_FinancialInstitutionId", + column: x => x.FinancialInstitutionId, + principalSchema: "public", + principalTable: "FinancialInstitution", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CreditCards_Wallets_DebitWalletId", + column: x => x.DebitWalletId, + principalSchema: "public", + principalTable: "Wallets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_CreditCards_CardBrandId", + schema: "public", + table: "CreditCards", + column: "CardBrandId"); + + migrationBuilder.CreateIndex( + name: "IX_CreditCards_DebitWalletId", + schema: "public", + table: "CreditCards", + column: "DebitWalletId"); + + migrationBuilder.CreateIndex( + name: "IX_CreditCards_FinancialInstitutionId", + schema: "public", + table: "CreditCards", + column: "FinancialInstitutionId"); + + migrationBuilder.CreateIndex( + name: "IX_CreditCards_Name_TenantId", + schema: "public", + table: "CreditCards", + columns: new[] { "Name", "TenantId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CreditCards", + schema: "public"); + } + } +} diff --git a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs index 7c5b1ef..e26effc 100644 --- a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs +++ b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs @@ -60,6 +60,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CardBrands", "public"); }); + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CardBrandId") + .HasColumnType("uuid"); + + b.Property("ClosingDay") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DebitWalletId") + .HasColumnType("uuid"); + + b.Property("DueDay") + .HasColumnType("integer"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") @@ -604,6 +676,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + 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") @@ -698,8 +794,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); }); @@ -714,6 +817,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("DeleteRequests"); }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + }); #pragma warning restore 612, 618 } } diff --git a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs index ced8b95..e00e410 100644 --- a/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs +++ b/Fin.Infrastructure/Seeders/Seeders/DefaultMenusSeeder.cs @@ -72,6 +72,28 @@ public async Task SeedAsync() OnlyForAdmin = true, Position = MenuPosition.LeftTop, KeyWords = "notifications, notificação, notificações" + }, + new() + { + Id = Guid.Parse("7826C06C-7F7D-4D92-BAFD-68B5D5F247A9"), + FrontRoute = "/admin/card-brand", + Name = "finCore.features.cardBrand.title", + Color = "#6d28d9", + Icon = "credit-card", + OnlyForAdmin = true, + Position = MenuPosition.LeftTop, + KeyWords = "card brand, bandeira, cartao" + }, + new() + { + Id = Guid.Parse("090183AC-2FBC-4DCE-BA22-CDD46B2C7494"), + FrontRoute = "/credit-cards", + Name = "finCore.features.creditCard.title", + Color = "#6d28d9", + Icon = "credit-card", + OnlyForAdmin = false, + Position = MenuPosition.LeftTop, + KeyWords = "credit card, cartao de crédito, cartão de credito, cartao" } }; var defaultMenusIds = defaultMenus.Select(x => x.Id).ToList(); diff --git a/Fin.Test/CreditCards/Services/CreditCardControllerTest.cs b/Fin.Test/CreditCards/Services/CreditCardControllerTest.cs new file mode 100644 index 0000000..236b96d --- /dev/null +++ b/Fin.Test/CreditCards/Services/CreditCardControllerTest.cs @@ -0,0 +1,316 @@ +using Fin.Api.CreditCards; +using Fin.Application.CreditCards.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Application.CreditCards.Services; +using Fin.Application.Globals.Dtos; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.Global.Classes; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace Fin.Test.CreditCards.Services; + +public class CreditCardControllerTest : TestUtils.BaseTest +{ + private readonly Mock _serviceMock; + private readonly CreditCardController _controller; + + public CreditCardControllerTest() + { + _serviceMock = new Mock(); + _controller = new CreditCardController(_serviceMock.Object); + } + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedOutput() + { + // Arrange + var input = new CreditCardGetListInput(); + var expectedOutput = new PagedOutput(1, + [ + new CreditCardOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] } + ]); + + _serviceMock.Setup(s => s.GetList(input)).ReturnsAsync(expectedOutput); + + // Act + var result = await _controller.GetList(input); + + // Assert + result.Should().BeEquivalentTo(expectedOutput); + } + + #endregion + + #region Get + + [Fact] + public async Task Get_ShouldReturnOk_WhenCreditCardExists() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var expectedCard = new CreditCardOutput { Id = cardId, Name = TestUtils.Strings[1] }; + _serviceMock.Setup(s => s.Get(cardId)).ReturnsAsync(expectedCard); + + // Act + var result = await _controller.Get(cardId); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(expectedCard); + } + + [Fact] + public async Task Get_ShouldReturnNotFound_WhenCreditCardDoesNotExist() + { + // Arrange + var cardId = TestUtils.Guids[0]; + _serviceMock.Setup(s => s.Get(cardId)).ReturnsAsync((CreditCardOutput)null); + + // Act + var result = await _controller.Get(cardId); + + // Assert + result.Result.Should().BeOfType(); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnCreated_WhenInputIsValid() + { + // Arrange + var input = new CreditCardInput { Name = TestUtils.Strings[1], Limit = 5000m }; + var createdCard = new CreditCardOutput { Id = TestUtils.Guids[0], Name = TestUtils.Strings[1] }; + var successResult = new ValidationResultDto + { + Success = true, + Data = createdCard + }; + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Create(input); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().Be(createdCard); + + // NOTE: The controller uses 'categories/{validationResult.Data?.Id}' as location, replicating the provided controller code. + (result.Result as CreatedResult)?.Location.Should().Be($"categories/{createdCard.Id}"); + } + + [Fact] + public async Task Create_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var input = new CreditCardInput(); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + _serviceMock.Setup(s => s.Create(input, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Create(input); + + // Assert + var unprocessableEntityResult = result.Result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnOk_WhenUpdateSucceeds() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var input = new CreditCardInput { Name = TestUtils.Strings[1], Limit = 5000m }; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.Update(cardId, input, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Update(cardId, input); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_ShouldReturnNotFound_WhenCreditCardDoesNotExist() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var input = new CreditCardInput(); + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardCreateOrUpdateErrorCode.CreditCardNotFound, + Message = "CreditCard not found to edit." + }; + _serviceMock.Setup(s => s.Update(cardId, input, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.Update(cardId, input); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task Update_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var input = new CreditCardInput(); + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardCreateOrUpdateErrorCode.NameAlreadyInUse, + Message = "Name is already in use." + }; + _serviceMock.Setup(s => s.Update(cardId, input, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Update(cardId, input); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region ToggleInactivated + + [Fact] + public async Task ToggleInactivated_ShouldReturnOk_WhenToggleSucceeds() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.ToggleInactive(cardId, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.ToggleInactivated(cardId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task ToggleInactivated_ShouldReturnNotFound_WhenCreditCardDoesNotExist() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardToggleInactiveErrorCode.CreditCardNotFound, + Message = "CreditCard not found." + }; + _serviceMock.Setup(s => s.ToggleInactive(cardId, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.ToggleInactivated(cardId); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task ToggleInactivated_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardToggleInactiveErrorCode.CreditCardNotFound, // Reusing error code for a general failure scenario + Message = "General validation failed." + }; + _serviceMock.Setup(s => s.ToggleInactive(cardId, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.ToggleInactivated(cardId); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnOk_WhenDeleteSucceeds() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var successResult = new ValidationResultDto { Success = true, Data = true }; + _serviceMock.Setup(s => s.Delete(cardId, true)).ReturnsAsync(successResult); + + // Act + var result = await _controller.Delete(cardId); + + // Assert + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_ShouldReturnNotFound_WhenCreditCardDoesNotExist() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var notFoundResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardDeleteErrorCode.CreditCardNotFound, + Message = "CreditCard not found." + }; + _serviceMock.Setup(s => s.Delete(cardId, true)).ReturnsAsync(notFoundResult); + + // Act + var result = await _controller.Delete(cardId); + + // Assert + var notFoundObjectResult = result.Should().BeOfType().Subject; + notFoundObjectResult.Value.Should().BeEquivalentTo(notFoundResult); + } + + [Fact] + public async Task Delete_ShouldReturnUnprocessableEntity_WhenValidationFails() + { + // Arrange + var cardId = TestUtils.Guids[0]; + var failureResult = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardDeleteErrorCode.CreditCardInUse, // Reusing error code for a general failure scenario + Message = "Cannot delete due to related transactions." + }; + _serviceMock.Setup(s => s.Delete(cardId, true)).ReturnsAsync(failureResult); + + // Act + var result = await _controller.Delete(cardId); + + // Assert + var unprocessableEntityResult = result.Should().BeOfType().Subject; + unprocessableEntityResult.Value.Should().BeEquivalentTo(failureResult); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/CreditCards/Services/CreditCardServiceTest.cs b/Fin.Test/CreditCards/Services/CreditCardServiceTest.cs new file mode 100644 index 0000000..49e2896 --- /dev/null +++ b/Fin.Test/CreditCards/Services/CreditCardServiceTest.cs @@ -0,0 +1,545 @@ +using Fin.Application.CreditCards.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Application.CreditCards.Services; +using Fin.Application.Globals.Dtos; +using Fin.Domain.CardBrands.Entities; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.CreditCards.Entities; +using Fin.Domain.Wallets.Entities; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace Fin.Test.CreditCards.Services; + +public class CreditCardServiceTest : TestUtils.BaseTestWithContext +{ + private readonly Mock _validationServiceMock; + + public CreditCardServiceTest() + { + _validationServiceMock = new Mock(); + } + + private CreditCardService GetService(Resources resources) + { + return new CreditCardService(resources.CreditCardRepository, _validationServiceMock.Object); + } + + private async Task GetResources() + { + var resource = new Resources + { + CreditCardRepository = GetRepository(), + DebitWalletId = TestUtils.Guids[9], + CardBrandId = TestUtils.Guids[8], + FinancialInstitutionId = TestUtils.Guids[7] + }; + + var financialInstitution = TestUtils.FinancialInstitutions[0]; + financialInstitution.Id = resource.FinancialInstitutionId; + var cardBrand = TestUtils.CardBrands[0]; + cardBrand.Id = resource.CardBrandId; + var wallet = TestUtils.Wallets[0]; + wallet.Id = resource.DebitWalletId; + wallet.FinancialInstitution = financialInstitution; + + await GetRepository().AddAsync(wallet, true); + await GetRepository().AddAsync(cardBrand, true); + + return resource; + } + + private CreditCard CreateCreditCard(CreditCardInput creditCard, Resources resources) + { + creditCard.FinancialInstitutionId = resources.FinancialInstitutionId; + creditCard.DebitWalletId = resources.DebitWalletId; + creditCard.CardBrandId = resources.CardBrandId; + creditCard.Color = TestUtils.Strings[8]; + creditCard.Icon = TestUtils.Strings[9]; + return new CreditCard(creditCard); + } + + private class Resources + { + public IRepository CreditCardRepository { get; set; } + public Guid FinancialInstitutionId { get; set; } + public Guid DebitWalletId { get; set; } + public Guid CardBrandId { get; set; } + } + + #region Get + + [Fact] + public async Task Get_ShouldReturnCreditCard_WhenExists() + { + var resources = await GetResources(); + var service = GetService(resources); + var input = new CreditCardInput + { + Name = "Visa Test", Limit = 5000, DueDay = 15, ClosingDay = 5, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }; + var creditCard = CreateCreditCard(input, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var result = await service.Get(creditCard.Id); + + result.Should().NotBeNull(); + result.Id.Should().Be(creditCard.Id); + result.Name.Should().Be(input.Name); + } + + [Fact] + public async Task Get_ShouldReturnNull_WhenNotExists() + { + var resources = await GetResources(); + var service = GetService(resources); + + var result = await service.Get(TestUtils.Guids[9]); + + result.Should().BeNull(); + } + + #endregion + + #region GetList + + [Fact] + public async Task GetList_ShouldReturnPagedResult_WithoutFilter() + { + var resources = await GetResources(); + var service = GetService(resources); + var brandId = TestUtils.Guids[0]; + var walletId = TestUtils.Guids[1]; + var fiId = TestUtils.Guids[2]; + + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "C", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, DebitWalletId = walletId, + FinancialInstitutionId = fiId + }, resources), true); + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "A", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, DebitWalletId = walletId, + FinancialInstitutionId = fiId + }, resources), true); + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "B", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, DebitWalletId = walletId, + FinancialInstitutionId = fiId + }, resources), true); + + var input = new CreditCardGetListInput { MaxResultCount = 2, SkipCount = 0 }; + + var result = await service.GetList(input); + + result.Should().NotBeNull(); + result.TotalCount.Should().Be(3); + result.Items.Should().HaveCount(2); + result.Items.First().Name.Should().Be("A"); + result.Items.Last().Name.Should().Be("B"); + } + + [Fact] + public async Task GetList_ShouldFilterByInactivatedTrue() + { + var resources = await GetResources(); + var service = GetService(resources); + var brandId = resources.CardBrandId; + var walletId = resources.DebitWalletId; + var fiId = resources.FinancialInstitutionId; + + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "Active1", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, + DebitWalletId = walletId, FinancialInstitutionId = fiId, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }, resources), true); + + var inactive = CreateCreditCard(new CreditCardInput + { + Name = "Inactive1", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, + DebitWalletId = walletId, FinancialInstitutionId = fiId, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }, resources); + inactive.ToggleInactivated(); + await resources.CreditCardRepository.AddAsync(inactive, true); + + var inactive2 = CreateCreditCard(new CreditCardInput + { + Name = "Inactive2", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId, + DebitWalletId = walletId, FinancialInstitutionId = fiId, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }, resources); + inactive2.ToggleInactivated(); + await resources.CreditCardRepository.AddAsync(inactive2, true); + + var input = new CreditCardGetListInput { Inactivated = true, MaxResultCount = 10, SkipCount = 0 }; + + var result = await service.GetList(input); + + result.Should().NotBeNull(); + result.TotalCount.Should().Be(2); + result.Items.Should().HaveCount(2); + result.Items.First().Name.Should().Be("Inactive1"); + result.Items.Last().Name.Should().Be("Inactive2"); + } + + [Fact] + public async Task GetList_ShouldFilterByMultipleIds() + { + var resources = await GetResources(); + var service = GetService(resources); + var brandId0 = TestUtils.Guids[0]; + var brandId1 = TestUtils.Guids[1]; + var walletId2 = TestUtils.Guids[2]; + var walletId3 = TestUtils.Guids[3]; + var fiId4 = TestUtils.Guids[4]; + var fiId5 = TestUtils.Guids[5]; + + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "CardA", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId0, + DebitWalletId = walletId2, FinancialInstitutionId = fiId4 + }, resources), true); + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "CardB", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId1, + DebitWalletId = walletId3, FinancialInstitutionId = fiId4 + }, resources), true); + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "CardC", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId0, + DebitWalletId = walletId3, FinancialInstitutionId = fiId5 + }, resources), true); + + var input = new CreditCardGetListInput + { + CardBrandIds = [brandId0], + DebitWalletIds = [walletId3], + FinancialInstitutionIds = [fiId4], + MaxResultCount = 10, + SkipCount = 0 + }; + + var result = await service.GetList(input); + + result.Should().NotBeNull(); + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetList_ShouldFilterCorrectly_WhenOneFilterIsMatch() + { + var resources = await GetResources(); + var service = GetService(resources); + var brandId0 = resources.CardBrandId; + var brandId1 = TestUtils.Guids[5]; + var walletId2 = resources.DebitWalletId; + var fiId4 = resources.FinancialInstitutionId; + + var cardBrand1 = TestUtils.CardBrands[1]; + cardBrand1.Id = brandId1; + await GetRepository().AddAsync(cardBrand1); + + await resources.CreditCardRepository.AddAsync( + CreateCreditCard(new CreditCardInput + { + Name = "CardA", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId0, + DebitWalletId = walletId2, FinancialInstitutionId = fiId4 + }, resources), true); + await resources.CreditCardRepository.AddAsync( + new CreditCard(new CreditCardInput + { + Name = "CardB", Limit = 100, DueDay = 1, ClosingDay = 1, CardBrandId = brandId1, + DebitWalletId = walletId2, FinancialInstitutionId = fiId4, Icon = TestUtils.Strings[1], Color = TestUtils.Strings[1], + }), true); + + var input = new CreditCardGetListInput + { + CardBrandIds = [brandId1], + MaxResultCount = 10, + SkipCount = 0 + }; + + var result = await service.GetList(input); + + result.Should().NotBeNull(); + result.TotalCount.Should().Be(1); + result.Items.Should().HaveCount(1); + result.Items.First().Name.Should().Be("CardB"); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_ShouldReturnSuccessAndCreditCard_WhenInputIsValid() + { + var resources = await GetResources(); + var service = GetService(resources); + var input = new CreditCardInput + { + Name = "Mastercard Black", + Color = "#000000", + Icon = "cc-mastercard", + Limit = 15000.50m, + DueDay = 15, + ClosingDay = 5, + CardBrandId = resources.CardBrandId, + DebitWalletId = resources.DebitWalletId, + FinancialInstitutionId = resources.FinancialInstitutionId + }; + + var successValidation = new ValidationResultDto + { Success = true }; + _validationServiceMock.Setup(v => v.ValidateInput(input, null)) + .ReturnsAsync(successValidation); + + var result = await service.Create(input, true); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + + var dbCreditCard = await resources.CreditCardRepository.Query(false) + .FirstOrDefaultAsync(a => a.Id == result.Data.Id); + dbCreditCard.Should().NotBeNull(); + dbCreditCard.Name.Should().Be(input.Name); + dbCreditCard.Limit.Should().Be(input.Limit); + dbCreditCard.ClosingDay.Should().Be(input.ClosingDay); + dbCreditCard.DebitWalletId.Should().Be(input.DebitWalletId); + } + + [Fact] + public async Task Create_ShouldReturnFailure_WhenValidationFails() + { + var resources = await GetResources(); + var service = GetService(resources); + var input = new CreditCardInput { Name = null }; + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardCreateOrUpdateErrorCode.NameIsRequired, + Message = "Name is required." + }; + _validationServiceMock.Setup(v => v.ValidateInput(input, null)) + .ReturnsAsync(failureValidation); + + var result = await service.Create(input, true); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.NameIsRequired); + result.Data.Should().BeNull(); + + (await resources.CreditCardRepository.Query(false).CountAsync()).Should().Be(0); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_ShouldReturnSuccess_WhenInputIsValid() + { + var resources = await GetResources(); + var service = GetService(resources); + var originalInput = new CreditCardInput + { + Name = "Old Card", Limit = 1000, DueDay = 1, ClosingDay = 25, CardBrandId = TestUtils.Guids[0], + DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] + }; + var creditCard = CreateCreditCard(originalInput, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var updatedInput = new CreditCardInput + { + Name = "New Card Name", + Color = "#FFFFFF", + Icon = "cc-visa", + Limit = 5000.75m, + DueDay = 10, + ClosingDay = 30, + CardBrandId = resources.CardBrandId, + DebitWalletId = resources.DebitWalletId, + FinancialInstitutionId = resources.FinancialInstitutionId + }; + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateInput(updatedInput, creditCard.Id)) + .ReturnsAsync(successValidation); + + var result = await service.Update(creditCard.Id, updatedInput, true); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + + var dbCreditCard = await resources.CreditCardRepository.Query(false).FirstAsync(a => a.Id == creditCard.Id); + dbCreditCard.Name.Should().Be(updatedInput.Name); + dbCreditCard.Limit.Should().Be(updatedInput.Limit); + dbCreditCard.DueDay.Should().Be(updatedInput.DueDay); + dbCreditCard.ClosingDay.Should().Be(updatedInput.ClosingDay); + dbCreditCard.CardBrandId.Should().Be(updatedInput.CardBrandId); + dbCreditCard.DebitWalletId.Should().Be(updatedInput.DebitWalletId); + } + + [Fact] + public async Task Update_ShouldReturnFailure_WhenValidationFails() + { + var resources = await GetResources(); + var service = GetService(resources); + var originalInput = new CreditCardInput + { + Name = "Old Card", Limit = 1000, DueDay = 1, ClosingDay = 25, CardBrandId = TestUtils.Guids[0], + DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] + }; + var creditCard = CreateCreditCard(originalInput, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var updatedInput = new CreditCardInput { Name = TestUtils.Strings[4], Limit = 5000 }; + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardCreateOrUpdateErrorCode.CardBrandNotFound, + Message = "Card Brand not found." + }; + _validationServiceMock.Setup(v => v.ValidateInput(updatedInput, creditCard.Id)) + .ReturnsAsync(failureValidation); + + var result = await service.Update(creditCard.Id, updatedInput, true); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.CardBrandNotFound); + result.Data.Should().BeFalse(); + + var dbCreditCard = await resources.CreditCardRepository.Query(false).FirstAsync(a => a.Id == creditCard.Id); + dbCreditCard.Name.Should().Be(originalInput.Name); + } + + #endregion + + #region Delete + + [Fact] + public async Task Delete_ShouldReturnSuccess_WhenValid() + { + var resources = await GetResources(); + var service = GetService(resources); + var creditCard = CreateCreditCard(new CreditCardInput + { + Name = TestUtils.Strings[0], Limit = 1000, DueDay = 1, ClosingDay = 25, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateDelete(creditCard.Id)).ReturnsAsync(successValidation); + + var result = await service.Delete(creditCard.Id, true); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + (await resources.CreditCardRepository.Query(false).FirstOrDefaultAsync(a => a.Id == creditCard.Id)).Should() + .BeNull(); + } + + [Fact] + public async Task Delete_ShouldReturnFailure_WhenValidationFails() + { + var resources = await GetResources(); + var service = GetService(resources); + var creditCard = CreateCreditCard(new CreditCardInput + { + Name = TestUtils.Strings[0], Limit = 1000, DueDay = 1, ClosingDay = 25, Color = TestUtils.Strings[9], Icon = TestUtils.Strings[9] + }, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardDeleteErrorCode.CreditCardInUse, + Message = "Cannot delete credit card with transactions." + }; + _validationServiceMock.Setup(v => v.ValidateDelete(creditCard.Id)).ReturnsAsync(failureValidation); + + var result = await service.Delete(creditCard.Id, true); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardDeleteErrorCode.CreditCardInUse); + (await resources.CreditCardRepository.Query(false).FirstOrDefaultAsync(a => a.Id == creditCard.Id)).Should() + .NotBeNull(); + } + + #endregion + + #region ToggleInactive + + [Fact] + public async Task ToggleInactive_ShouldReturnSuccess_WhenValidAndDeactivate() + { + var resources = await GetResources(); + var service = GetService(resources); + var creditCard = CreateCreditCard(new CreditCardInput + { + Name = TestUtils.Strings[0], Limit = 1000, DueDay = 1, ClosingDay = 25, CardBrandId = TestUtils.Guids[0], + DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] + }, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + creditCard.Inactivated.Should().BeFalse(); + + var successValidation = new ValidationResultDto { Success = true }; + _validationServiceMock.Setup(v => v.ValidateToggleInactive(creditCard.Id)).ReturnsAsync(successValidation); + + var result = await service.ToggleInactive(creditCard.Id, true); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + result.Data.Should().BeTrue(); + var dbCreditCard = await resources.CreditCardRepository.Query(false).FirstAsync(a => a.Id == creditCard.Id); + dbCreditCard.Inactivated.Should().BeTrue(); + } + + [Fact] + public async Task ToggleInactive_ShouldReturnFailure_WhenValidationFails() + { + var resources = await GetResources(); + var service = GetService(resources); + var creditCard = CreateCreditCard(new CreditCardInput + { + Name = TestUtils.Strings[0], Limit = 1000, DueDay = 1, ClosingDay = 25, CardBrandId = TestUtils.Guids[0], + DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] + }, resources); + await resources.CreditCardRepository.AddAsync(creditCard, true); + creditCard.Inactivated.Should().BeFalse(); + + var failureValidation = new ValidationResultDto + { + Success = false, + ErrorCode = CreditCardToggleInactiveErrorCode.CreditCardNotFound, + Message = "Credit Card not found." + }; + _validationServiceMock.Setup(v => v.ValidateToggleInactive(creditCard.Id)).ReturnsAsync(failureValidation); + + var result = await service.ToggleInactive(creditCard.Id, true); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardToggleInactiveErrorCode.CreditCardNotFound); + var dbCreditCard = await resources.CreditCardRepository.Query(false).FirstAsync(a => a.Id == creditCard.Id); + dbCreditCard.Inactivated.Should().BeFalse(); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/CreditCards/Services/CreditCardValidationServiceTest.cs b/Fin.Test/CreditCards/Services/CreditCardValidationServiceTest.cs new file mode 100644 index 0000000..bab2aea --- /dev/null +++ b/Fin.Test/CreditCards/Services/CreditCardValidationServiceTest.cs @@ -0,0 +1,454 @@ +using Fin.Application.CardBrands; +using Fin.Application.FinancialInstitutions; +using Fin.Application.Globals.Dtos; +using Fin.Application.CreditCards.Enums; +using Fin.Application.CreditCards.Services; +using Fin.Application.Wallets.Services; +using Fin.Domain.CreditCards.Dtos; +using Fin.Domain.CreditCards.Entities; +using Fin.Domain.FinancialInstitutions.Dtos; +using Fin.Domain.Wallets.Dtos; +using Fin.Infrastructure.Database.Repositories; +using FluentAssertions; +using Moq; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Test.CreditCards.Services; + +public class CreditCardValidationServiceTest : TestUtils.BaseTestWithContext +{ + private CreditCardValidationService GetService(Resources resources) + { + return new CreditCardValidationService( + resources.CreditCardRepository, + resources.FakeFinancialInstitution.Object, + resources.FakeWalletService.Object, + resources.FakeCardBrandService.Object + ); + } + + private Resources GetResources() + { + return new Resources + { + CreditCardRepository = GetRepository(), + FakeFinancialInstitution = new Mock(), + FakeWalletService = new Mock(), + FakeCardBrandService = new Mock() + }; + } + + private class Resources + { + public IRepository CreditCardRepository { get; set; } + public Mock FakeFinancialInstitution { get; set; } + public Mock FakeWalletService { get; set; } + public Mock FakeCardBrandService { get; set; } + } + + #region ValidateToggleInactive + + [Fact] + public async Task ValidateToggleInactive_ShouldReturnSuccess_WhenCreditCardExists() + { + var resources = GetResources(); + var service = GetService(resources); + var input = new CreditCardInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], CardBrandId = TestUtils.Guids[0], DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] }; + var creditCard = new CreditCard(input); + creditCard.CardBrand = TestUtils.CardBrands[0]; + creditCard.FinancialInstitution = TestUtils.FinancialInstitutions[0]; + creditCard.DebitWallet = TestUtils.Wallets[0]; + creditCard.DebitWallet.FinancialInstitution = creditCard.FinancialInstitution; + + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var result = await service.ValidateToggleInactive(creditCard.Id); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateToggleInactive_ShouldReturnFailure_WhenCreditCardNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + var result = await service.ValidateToggleInactive(nonExistentId); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardToggleInactiveErrorCode.CreditCardNotFound); + result.Message.Should().Be("CreditCard not found to toggle inactive."); + } + + #endregion + + #region ValidateDelete + + [Fact] + public async Task ValidateDelete_ShouldReturnSuccess_WhenCreditCardExists() + { + var resources = GetResources(); + var service = GetService(resources); + var input = new CreditCardInput { Name = TestUtils.Strings[0], Color = TestUtils.Strings[1], Icon = TestUtils.Strings[2], CardBrandId = TestUtils.Guids[0], DebitWalletId = TestUtils.Guids[1], FinancialInstitutionId = TestUtils.Guids[2] }; + var creditCard = new CreditCard(input); + creditCard.CardBrand = TestUtils.CardBrands[0]; + creditCard.FinancialInstitution = TestUtils.FinancialInstitutions[0]; + creditCard.DebitWallet = TestUtils.Wallets[0]; + creditCard.DebitWallet.FinancialInstitution = creditCard.FinancialInstitution; + + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var result = await service.ValidateDelete(creditCard.Id); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateDelete_ShouldReturnFailure_WhenCreditCardNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + + var result = await service.ValidateDelete(nonExistentId); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardDeleteErrorCode.CreditCardNotFound); + result.Message.Should().Be("CreditCard not found to delete."); + } + + // NOTE: 'TODO here validate relations' exists in the service, but since the implementation is missing, we only test the existing logic. + + #endregion + + #region ValidateInput (Create and Update) + + private CreditCardInput GetValidInput() => new() + { + Name = "New Card", + Color = "#FFFFFF", + Icon = "fa-credit-card", + Limit = 1000m, + DueDay = 15, + ClosingDay = 5, + CardBrandId = TestUtils.Guids[0], + DebitWalletId = TestUtils.Guids[1], + FinancialInstitutionId = TestUtils.Guids[2] + }; + + private void SetupDependenciesSuccess(Resources resources) + { + var fiId = TestUtils.Guids[2]; + var walletId = TestUtils.Guids[1]; + var brandId = TestUtils.Guids[0]; + + var activeInstitution = new FinancialInstitutionOutput { Id = fiId, Inactive = false }; + resources.FakeFinancialInstitution.Setup(s => s.Get(fiId)).ReturnsAsync(activeInstitution); + + var activeWallet = new WalletOutput { Id = walletId, Inactivated = false }; + resources.FakeWalletService.Setup(s => s.Get(walletId)).ReturnsAsync(activeWallet); + + resources.FakeCardBrandService.Setup(s => s.Get(brandId)).ReturnsAsync(new CardBrandOutput { Id = brandId }); + } + + [Fact] + public async Task ValidateInput_Create_ShouldReturnSuccess_WhenValid() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + SetupDependenciesSuccess(resources); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_Update_ShouldReturnSuccess_WhenValid() + { + var resources = GetResources(); + var service = GetService(resources); + var originalInput = GetValidInput(); + var creditCard = new CreditCard(originalInput); + creditCard.CardBrand = TestUtils.CardBrands[0]; + creditCard.FinancialInstitution = TestUtils.FinancialInstitutions[0]; + creditCard.DebitWallet = TestUtils.Wallets[0]; + creditCard.DebitWallet.FinancialInstitution = creditCard.FinancialInstitution; + + await resources.CreditCardRepository.AddAsync(creditCard, true); + var input = GetValidInput(); + SetupDependenciesSuccess(resources); + + var result = await service.ValidateInput(input, creditCard.Id); + + result.Should().NotBeNull(); + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task ValidateInput_Update_ShouldReturnFailure_WhenCreditCardNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var nonExistentId = TestUtils.Guids[9]; + var input = GetValidInput(); + + var result = await service.ValidateInput(input, nonExistentId); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.CreditCardNotFound); + result.Message.Should().Be("CreditCard not found to edit."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenNameIsRequired(string name) + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Name = name; + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.NameIsRequired); + result.Message.Should().Be("Name is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameTooLong() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Name = new string('A', 101); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.NameTooLong); + result.Message.Should().Be("Name is too long. Max 100 characters."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenNameAlreadyInUseOnCreate() + { + var resources = GetResources(); + var service = GetService(resources); + var existingName = TestUtils.Strings[0]; + + var creditCard = new CreditCard(new CreditCardInput + { + Name = existingName, CardBrandId = TestUtils.Guids[9], DebitWalletId = TestUtils.Guids[9], + Color = TestUtils.Strings[9], + Icon = TestUtils.Strings[9], + }); + creditCard.CardBrand = TestUtils.CardBrands[0]; + creditCard.FinancialInstitution = TestUtils.FinancialInstitutions[0]; + creditCard.DebitWallet = TestUtils.Wallets[0]; + creditCard.DebitWallet.FinancialInstitution = creditCard.FinancialInstitution; + + await resources.CreditCardRepository.AddAsync(creditCard, true); + + var input = GetValidInput(); + input.Name = existingName; + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.NameAlreadyInUse); + result.Message.Should().Be("Name is already in use."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenFinancialInstitutionNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + resources.FakeFinancialInstitution.Setup(s => s.Get(input.FinancialInstitutionId)).ReturnsAsync((FinancialInstitutionOutput)null); + resources.FakeWalletService.Setup(s => s.Get(input.DebitWalletId)).ReturnsAsync(new WalletOutput()); + resources.FakeCardBrandService.Setup(s => s.Get(input.CardBrandId)).ReturnsAsync(new CardBrandOutput()); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.FinancialInstitutionNotFound); + result.Message.Should().Be("Financial institution not found."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenFinancialInstitutionInactivated() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + var inactiveInstitution = new FinancialInstitutionOutput { Id = input.FinancialInstitutionId, Inactive = true }; + resources.FakeFinancialInstitution.Setup(s => s.Get(input.FinancialInstitutionId)).ReturnsAsync(inactiveInstitution); + resources.FakeWalletService.Setup(s => s.Get(input.DebitWalletId)).ReturnsAsync(new WalletOutput()); + resources.FakeCardBrandService.Setup(s => s.Get(input.CardBrandId)).ReturnsAsync(new CardBrandOutput()); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.FinancialInstitutionInactivated); + result.Message.Should().Be("Financial institution is inactive."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenDebitWalletNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + var activeInstitution = new FinancialInstitutionOutput { Id = input.FinancialInstitutionId, Inactive = false }; + resources.FakeFinancialInstitution.Setup(s => s.Get(input.FinancialInstitutionId)).ReturnsAsync(activeInstitution); + resources.FakeWalletService.Setup(s => s.Get(input.DebitWalletId)).ReturnsAsync((WalletOutput)null); + resources.FakeCardBrandService.Setup(s => s.Get(input.CardBrandId)).ReturnsAsync(new CardBrandOutput()); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.DebitWalletNotFound); + result.Message.Should().Be("Debit wallet not found."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenDebitWalletInactivated() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + var activeInstitution = new FinancialInstitutionOutput { Id = input.FinancialInstitutionId, Inactive = false }; + resources.FakeFinancialInstitution.Setup(s => s.Get(input.FinancialInstitutionId)).ReturnsAsync(activeInstitution); + + var inactiveWallet = new WalletOutput { Id = input.DebitWalletId, Inactivated = true }; + resources.FakeWalletService.Setup(s => s.Get(input.DebitWalletId)).ReturnsAsync(inactiveWallet); + resources.FakeCardBrandService.Setup(s => s.Get(input.CardBrandId)).ReturnsAsync(new CardBrandOutput()); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.DebitWalletInactivated); + result.Message.Should().Be("Debit wallet is inactive."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenCardBrandNotFound() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + + var activeInstitution = new FinancialInstitutionOutput { Id = input.FinancialInstitutionId, Inactive = false }; + resources.FakeFinancialInstitution.Setup(s => s.Get(input.FinancialInstitutionId)).ReturnsAsync(activeInstitution); + + var activeWallet = new WalletOutput { Id = input.DebitWalletId, Inactivated = false }; + resources.FakeWalletService.Setup(s => s.Get(input.DebitWalletId)).ReturnsAsync(activeWallet); + + resources.FakeCardBrandService.Setup(s => s.Get(input.CardBrandId)).ReturnsAsync((CardBrandOutput)null); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.CardBrandNotFound); + result.Message.Should().Be("CardBrand not found."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenColorIsRequired(string color) + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Color = color; + SetupDependenciesSuccess(resources); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.ColorIsRequired); + result.Message.Should().Be("Color is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenColorTooLong() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Color = new string('A', 21); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.ColorTooLong); + result.Message.Should().Be("Color is too long. Max 20 characters."); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateInput_ShouldReturnFailure_WhenIconIsRequired(string icon) + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Icon = icon; + SetupDependenciesSuccess(resources); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.IconIsRequired); + result.Message.Should().Be("Icon is required."); + } + + [Fact] + public async Task ValidateInput_ShouldReturnFailure_WhenIconTooLong() + { + var resources = GetResources(); + var service = GetService(resources); + var input = GetValidInput(); + input.Icon = new string('A', 21); + + var result = await service.ValidateInput(input); + + result.Should().NotBeNull(); + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be(CreditCardCreateOrUpdateErrorCode.IconTooLong); + result.Message.Should().Be("Icon is too long. Max 20 characters."); + } + + #endregion +} \ No newline at end of file diff --git a/Fin.Test/TestUtils.cs b/Fin.Test/TestUtils.cs index 91db448..a76717c 100644 --- a/Fin.Test/TestUtils.cs +++ b/Fin.Test/TestUtils.cs @@ -1,13 +1,16 @@ -using Fin.Domain.Global.Interfaces; +using Fin.Domain.CardBrands.Entities; +using Fin.Domain.FinancialInstitutions.Entities; +using Fin.Domain.FinancialInstitutions.Enums; using Fin.Domain.Tenants.Entities; using Fin.Domain.Users.Entities; +using Fin.Domain.Wallets.Dtos; +using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.AmbientDatas; using Fin.Infrastructure.Database; using Fin.Infrastructure.Database.Repositories; using Fin.Infrastructure.DateTimes; using Fin.Infrastructure.UnitOfWorks; using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Moq; namespace Fin.Test; @@ -29,27 +32,29 @@ protected async Task ConfigureLoggedAmbientAsync(bool isAdmin = true) await Task.CompletedTask; } } - - public class BaseTestWithContext: BaseTest, IDisposable + + public class BaseTestWithContext : BaseTest, IDisposable { protected readonly FinDbContext Context; protected readonly UnitOfWork UnitOfWork; private readonly SqliteConnection _connection; private readonly string _dbFilePath; - + protected BaseTestWithContext() { var dateTimeProviderMockForContext = new Mock(); - Context = TestDbContextFactory.Create(out _connection, out _dbFilePath, AmbientData, dateTimeProviderMockForContext.Object, useFile: true); + Context = TestDbContextFactory.Create(out _connection, out _dbFilePath, AmbientData, + dateTimeProviderMockForContext.Object, useFile: true); UnitOfWork = new UnitOfWork(Context); } public void Dispose() { Context.Dispose(); - TestDbContextFactory.Destroy(_connection, _dbFilePath);; + TestDbContextFactory.Destroy(_connection, _dbFilePath); + ; } - + protected IRepository GetRepository() where T : class { return new Repository(Context); @@ -60,7 +65,7 @@ protected IRepository GetRepository() where T : class var user = new User { Id = Guids[0], - Tenants = [ new Tenant() ], + Tenants = [new Tenant()], Credential = new UserCredential() }; if (isAdmin) user.MakeAdmin(); @@ -70,7 +75,7 @@ protected IRepository GetRepository() where T : class AmbientData.SetData(user.Tenants.First().Id, user.Id, user.DisplayName, user.IsAdmin); } } - + public static List Guids => [ Guid.Parse("3f5e2a76-9c4d-45f7-b798-8412ad4cfb6d"), @@ -84,7 +89,7 @@ protected IRepository GetRepository() where T : class Guid.Parse("fd933db3-74d9-423b-bdb5-9c16fa91d1d7"), Guid.Parse("6f4d9ef4-3211-46b2-abe1-c87ef6a39db7") ]; - + public static List Strings => [ "alpha-923", @@ -99,6 +104,20 @@ protected IRepository GetRepository() where T : class "Zebra@Night" ]; + public static List Decimals => + [ + 100.00m, + 45.50m, + 0.00m, + -12.75m, + 99999.99m, + 1.234567m, + 1000m, + -500.00m, + 123456.78m, + 2.5m + ]; + public static List UtcDateTimes => [ new(2023, 01, 01, 0, 0, 0, DateTimeKind.Utc), @@ -126,4 +145,40 @@ protected IRepository GetRepository() where T : class new(10, 10, 10), // 10:10:10 new(7, 20, 5) ]; + + public static List CardBrands => + [ + new() { Name = Strings[0], Color = Strings[1], Icon = Strings[2] }, + new() { Name = Strings[2], Color = Strings[3], Icon = Strings[4] }, + new() { Name = Strings[4], Color = Strings[5], Icon = Strings[6] }, + new() { Name = Strings[6], Color = Strings[7], Icon = Strings[8] }, + new() { Name = Strings[8], Color = Strings[9], Icon = Strings[0] } + ]; + + public static List FinancialInstitutions => + [ + new() { Name = Strings[0], Color = Strings[1], Icon = Strings[2], Type = FinancialInstitutionType.Bank }, + new() { Name = Strings[2], Color = Strings[3], Icon = Strings[4], Type = FinancialInstitutionType.DigitalBank }, + new() { Name = Strings[4], Color = Strings[5], Icon = Strings[6], Type = FinancialInstitutionType.FoodCard }, + new() { Name = Strings[6], Color = Strings[7], Icon = Strings[8], Type = FinancialInstitutionType.DigitalBank }, + new() { Name = Strings[8], Color = Strings[9], Icon = Strings[0], Type = FinancialInstitutionType.Bank } + ]; + + public static List WalletsInputs => + [ + new() { Name = Strings[0], Color = Strings[1], Icon = Strings[2], InitialBalance = Decimals[0] }, + new() { Name = Strings[2], Color = Strings[3], Icon = Strings[4], InitialBalance = Decimals[1] }, + new() { Name = Strings[4], Color = Strings[5], Icon = Strings[6], InitialBalance = Decimals[2] }, + new() { Name = Strings[6], Color = Strings[7], Icon = Strings[8], InitialBalance = Decimals[3] }, + new() { Name = Strings[8], Color = Strings[9], Icon = Strings[0], InitialBalance = Decimals[4] } + ]; + + public static List Wallets => + [ + new(WalletsInputs[0]), + new(WalletsInputs[1]), + new(WalletsInputs[2]), + new(WalletsInputs[3]), + new(WalletsInputs[4]) + ]; } \ No newline at end of file diff --git a/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs index 1e49ba3..3dd2024 100644 --- a/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs +++ b/Fin.Test/Wallets/Services/WalletValidationServiceTest.cs @@ -1,6 +1,7 @@ using Fin.Application.FinancialInstitutions; using Fin.Application.Wallets.Enums; using Fin.Application.Wallets.Services; +using Fin.Domain.CreditCards.Entities; using Fin.Domain.FinancialInstitutions.Dtos; using Fin.Domain.Wallets.Dtos; using Fin.Domain.Wallets.Entities; @@ -16,7 +17,7 @@ public class WalletValidationServiceTest : TestUtils.BaseTestWithContext private WalletValidationService GetService(Resources resources) { - return new WalletValidationService(resources.WalletRepository, resources.FakeFinancialInstitution.Object); + return new WalletValidationService(resources.WalletRepository, resources.CreditCardRepository, resources.FakeFinancialInstitution.Object); } private Resources GetResources() @@ -24,6 +25,7 @@ private Resources GetResources() return new Resources { WalletRepository = GetRepository(), + CreditCardRepository = GetRepository(), FakeFinancialInstitution = new Mock() }; } @@ -31,6 +33,7 @@ private Resources GetResources() private class Resources { public IRepository WalletRepository { get; set; } + public IRepository CreditCardRepository { get; set; } public Mock FakeFinancialInstitution { get; set; } } @@ -68,7 +71,7 @@ public async Task ValidateToggleInactive_ShouldReturnFailure_WhenWalletNotFound( result.Should().NotBeNull(); result.Success.Should().BeFalse(); result.ErrorCode.Should().Be(WalletToggleInactiveErrorCode.WalletNotFound); - result.Message.Should().Be("Wallet not found to toogle inactive."); + result.Message.Should().Be("Wallet not found to toggle inactive."); } #endregion