diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..bc78471db --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/README.md b/README.md index 664da6305..c7f804513 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,24 @@ -# Back End Test Project +# Setup -You should see this challenge as an opportunity to create an application following modern development best practices (given the stack of your choice), but also feel free to use your own architecture preferences (coding standards, code organization, third-party libraries, etc). It’s perfectly fine to use vanilla code or any framework or libraries. +- Necesário: .net 8 +- Necessário: docker ou postgres, coloquei aqui numa porta diferente o mapeamento caso já tenha outro rodando na padrão -## Scope +Banco: +docker run --name api-investments-postgres -e POSTGRES_PASSWORD=1q2w3e4r@@@ -d -p 5436:5432 postgres -In this challenge you should build an **API** for an application that stores and manages investments, it should have the following features: -1. __Creation__ of an investment with an owner, a creation date and an amount. - 1. The creation date of an investment can be today or a date in the past. - 2. An investment should not be or become negative. -2. __View__ of an investment with its initial amount and expected balance. - 1. Expected balance should be the sum of the invested amount and the [gains][]. - 2. If an investment was already withdrawn then the balance must reflect the gains of that investment -3. __Withdrawal__ of a investment. - 1. The withdraw will always be the sum of the initial amount and its gains, - partial withdrawn is not supported. - 2. The withdrawal date must be informmed by the user, and it can be a date in the past or today, but can't happen before the investment creation or the future. - 3. [Taxes][taxes] need to be applied to the withdrawals before showing the final value. -4. __List__ of a person's investments - 1. This list should have pagination. +- Stringconnection pelo user secrets ( mas também pode ser variável de ambiente normal) +dotnet user-secrets set conexao "User ID=postgres;Password=1q2w3e4r@@@;Host=localhost;Port=5436;Database=ClienteDB;Pooling=true;" -__NOTE:__ the implementation of an interface will not be evaluated, and if implemented the endpoints still need to be usable/readable for a machine. +# Libs +- Identity: Para autenticação e autorização, usei pois agiliza bastante a autenticação e autorização +- entityFrameWorkCore.Design: para gerar as migrações, ferramente muito boa de code em code first +- Swashbuckle.AspNetCore.Annotations: para fazer a documentação da api pelo swagger. +- EntityFrameworkCore.PostgreSQL: Oferece orm para lidar com banco postgres, também uma execelente lib com boa performance atualmente. Gostaria de ter inserido também o dapper para operações que exigissem um controle mais perfomátíco, mas pelo escopo acredito que o desempenho do entity é suficiente. +- Moq: Me auxiliou no mock do repositório para testes unitários e focar apenas nos serços e lógicas de negócio. + +# Run +- Todos os projetos estão .net8, então basta ter .net 8 sdk instalado e rodar "dotnet run" ele vai restaurar pacotes, compilar e rodar. -### Gain Calculation - -The investment will pay 0.52% every month in the same day of the investment creation. - -Given that the gain is paid every month, it should be treated as [compound gain][], which means that every new period (month) the amount gained will become part of the investment balance for the next payment. - -### Taxation - -When money is withdrawn, tax is triggered. Taxes apply only to the gain portion of the money withdrawn. For example, if the initial investment was 1000.00, the current balance is 1200.00, then the taxes will be applied to the 200.00. - -The tax percentage changes according to the age of the investment: -* If it is less than one year old, the percentage will be 22.5% (tax = 45.00). -* If it is between one and two years old, the percentage will be 18.5% (tax = 37.00). -* If older than two years, the percentage will be 15% (tax = 30.00). - -## Requirements -1. Create project using any technology of your preference. It’s perfectly OK to use vanilla code or any framework or libraries; -2. Although you can use as many dependencies as you want, you should manage them wisely; -3. It is not necessary to send the notification emails, however, the code required for that would be welcome; -4. The API must be documented in some way. - -## Deliverables -The project source code and dependencies should be made available in GitHub. Here are the steps you should follow: -1. Fork this repository to your GitHub account (create an account if you don't have one, you will need it working with us). -2. Create a "development" branch and commit the code to it. Do not push the code to the main branch. -3. Include a README file that describes: - - Special build instructions, if any - - List of third-party libraries used and short description of why/how they were used - - A link to the API documentation. -4. Once the work is complete, create a pull request from "development" into "main" and send us the link. -5. Avoid using huge commits hiding your progress. Feel free to work on a branch and use `git rebase` to adjust your commits before submitting the final version. - -## Coding Standards -When working on the project be as clean and consistent as possible. - -## Project Deadline -Ideally you'd finish the test project in 5 days. It shouldn't take you longer than a entire week. - -## Quality Assurance -Use the following checklist to ensure high quality of the project. - -### General -- First of all, the application should run without errors. -- Are all requirements set above met? -- Is coding style consistent? -- The API is well documented? -- The API has unit tests? - -## Submission -1. A link to the Github repository. -2. Briefly describe how you decided on the tools that you used. - -## Have Fun Coding 🤘 -- This challenge description is intentionally vague in some aspects, but if you need assistance feel free to ask for help. -- If any of the seems out of your current level, you may skip it, but remember to tell us about it in the pull request. - -## Credits - -This coding challenge was inspired on [kinvoapp/kinvo-back-end-test](https://github.com/kinvoapp/kinvo-back-end-test/blob/2f17d713de739e309d17a1a74a82c3fd0e66d128/README.md) - -[gains]: #gain-calculation -[taxes]: #taxation -[interest]: #interest-calculation -[compound gain]: https://www.investopedia.com/terms/g/gain.asp +# Obs +- Deixei as migrações sendo aplicadas no startup da aplicação, mas apenas facilitar o trabalho do avaliador; geralmente coloco isso no pipeline de publicação de uma realease e fica separado num passo de ci/cd. \ No newline at end of file diff --git a/api/Common/Api/BuilderExtension.cs b/api/Common/Api/BuilderExtension.cs new file mode 100644 index 000000000..ea2ab2ec1 --- /dev/null +++ b/api/Common/Api/BuilderExtension.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using application.Services; +using core.Repositories; +using core.Services; +using infra.Data; +using infra.Repositories; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; + +namespace api.Common.Api +{ + public static class BuilderExtension + { + public static void AddConfiguration(this WebApplicationBuilder builder) + { + Configuration.ConnectionString = builder.Configuration.GetValue("conexao") ?? string.Empty; + + } + + public static void AddDocumentation(this WebApplicationBuilder builder) + { + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.EnableAnnotations(); + options.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Investment API", + Description = "Web API for investments", + TermsOfService = new Uri("https://example.com/terms"), + Contact = new OpenApiContact + { + Name = "Investment API Contact", + Url = new Uri("https://example.com/contact") + }, + License = new OpenApiLicense + { + Name = "Investment API License", + Url = new Uri("https://example.com/license") + } + }); + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); + }); + + + } + + public static void AddSecurity(this WebApplicationBuilder builder) + { + builder.Services.AddIdentityApiEndpoints(); + + + builder.Services.AddAuthorization(); + } + + public static void AddDataContexts(this WebApplicationBuilder builder) + { + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(Configuration.ConnectionString); + }); + + builder.Services.AddIdentityCore() + .AddEntityFrameworkStores() + .AddApiEndpoints() + ; + } + + + public static void AddServices(this WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/api/Common/Api/Configuration.cs b/api/Common/Api/Configuration.cs new file mode 100644 index 000000000..b8ed82086 --- /dev/null +++ b/api/Common/Api/Configuration.cs @@ -0,0 +1,8 @@ +namespace api.Common.Api +{ + public static class Configuration + { + public static string ConnectionString { get; set; } = string.Empty; + + } +} \ No newline at end of file diff --git a/api/Controllers/IdentityController.cs b/api/Controllers/IdentityController.cs new file mode 100644 index 000000000..dba71a208 --- /dev/null +++ b/api/Controllers/IdentityController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class IdentityController : ControllerBase + { + private readonly SignInManager _signInManager; + + public IdentityController(SignInManager signInManager) + { + _signInManager = signInManager; + } + + [Authorize] + [HttpPost("logout")] + public async Task Logout() + { + await _signInManager.SignOutAsync(); + return Ok(); + } + } +} \ No newline at end of file diff --git a/api/Controllers/InvestmentController.cs b/api/Controllers/InvestmentController.cs new file mode 100644 index 000000000..bf22e648e --- /dev/null +++ b/api/Controllers/InvestmentController.cs @@ -0,0 +1,210 @@ +using application.DTOs; +using application.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using common.TypeExtentions; +using core.Filters; + +namespace api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class InvestmentController : ControllerBase + { + private readonly IInvestmentService _investmentService; + + public InvestmentController(IInvestmentService investmentService) + { + _investmentService = investmentService; + } + + /// + /// ### Exemplo de requisição + /// ```json + /// { + /// "amount": 1500.00, + /// "creationDate": "2023-04-10" + /// } + /// ``` + /// + /// ### Regras: + /// - O valor inicial **não pode ser negativo** + /// - A data de criação pode ser **hoje ou no passado** + /// - O dono é atribuído internamente a partir do contexto do usuário autenticado + /// + /// ### Ganhos: + /// - O investimento rende **0.52% ao mês** + /// - Regra de **juros compostos** + /// + [Authorize] + [HttpPost] + [SwaggerOperation( + Summary = "Cria um novo investimento", + Description = "Cria um investimento definindo dono, data de criação e valor inicial.", + OperationId = "CreateInvestment", + Tags = new[] { "Investments" } + )] + [SwaggerResponse(StatusCodes.Status200OK, "Investimento criado com sucesso", typeof(InvestmentDto))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Erro de validação ou dados inválidos")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Usuário não autenticado")] + public async Task> CreateInvestment( + [FromBody, SwaggerRequestBody("Dados para criação de um novo investimento")] CreateInvestmentInput input) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var userId = User.UserId(); + if (string.IsNullOrEmpty(userId)) + return BadRequest("Usuário não autenticado."); + + input.SetOwner(userId); + + try + { + var investment = await _investmentService.CreateInvestmentAsync(input); + return Ok(investment); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + + /// + /// ### Retorno inclui: + /// - Valor inicial + /// - Data de criação + /// - Saldo esperado (valor + ganhos) + /// - Data de retirada (quando existir) + /// - Valor líquido recebido após impostos (se retirado) + /// + /// ### Regras de ganhos: + /// - 0.52% ao mês + /// - Juros compostos + /// + /// Se o investimento já foi retirado: + /// - O saldo reflete somente os ganhos até a data do saque + /// - O campo **NetWithdrawAmount** no retorno representa o valor final após impostos, só é preenchido caso tenha sido retirado. + /// + [Authorize] + [HttpGet("{id:guid}")] + [SwaggerOperation( + Summary = "Obtém um investimento pelo ID", + Description = "Retorna os detalhes completos do investimento.", + OperationId = "GetInvestmentById", + Tags = new[] { "Investments" } + )] + [SwaggerResponse(StatusCodes.Status200OK, "Investimento encontrado", typeof(InvestmentDto))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Investimento não encontrado")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Usuário não autenticado")] + public async Task> GetInvestmentById(Guid id) + { + var userId = User.UserId(); + if (string.IsNullOrEmpty(userId)) + return BadRequest("Usuário não autenticado."); + + var investment = await _investmentService.GetInvestmentByIdAsync( + new core.Filters.InvestmentFilter(userId) { Id = id }); + + if (investment == null) + return NotFound(); + + return Ok(investment); + } + + /// + /// ### Exemplo: + /// + /// GET /api/investment/owner?page=1&pageSize=10 + /// + /// ### Detalhes: + /// - Lista **todos os investimentos do usuário atual** + /// - Resposta é **paginada** + /// - Inclui total de itens e total de páginas + /// + [Authorize] + [HttpGet("owner")] + [SwaggerOperation( + Summary = "Lista investimentos do usuário", + Description = "Retorna uma lista paginada de investimentos pertencentes ao usuário atual.", + OperationId = "GetInvestmentsByOwner", + Tags = new[] { "Investments" } + )] + [SwaggerResponse(StatusCodes.Status200OK, "Lista retornada com sucesso", typeof(PaginatedResult))] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Usuário não autenticado")] + public async Task>> GetInvestmentsByOwner( + [FromQuery, SwaggerParameter("Página atual (mínimo 1)")] int page = 1, + [FromQuery, SwaggerParameter("Quantidade de itens por página")] int pageSize = 10) + { + var userId = User.UserId(); + if (string.IsNullOrEmpty(userId)) + return BadRequest("Usuário não autenticado."); + + var result = await _investmentService.GetInvestmentsByOwnerAsync( + new InvestmentFilter(userId) { Page = page, PageSize = pageSize }); + + return Ok(result); + } + + // --------------------------------------------------------- + // POST /api/investment/{id}/withdraw + // Withdraw investment + // --------------------------------------------------------- + + /// + /// ### Exemplo de requisição + /// ```json + /// { + /// "withdrawalDate": "2025-11-30T18:11:06.825Z" + /// } + /// ``` + /// + /// ### Regras: + /// - O saque é sempre **total (100%)** + /// - A data de saque não pode ser: + /// - antes da criação + /// - no futuro + /// + /// ### Tributação aplicada ao ganho: + /// - Menos de 1 ano → **22.5%** + /// - Entre 1 e 2 anos → **18.5%** + /// - Mais de 2 anos → **15%** + /// + /// + [Authorize] + [HttpPost("{id:guid}/withdraw")] + [SwaggerOperation( + Summary = "Realiza o saque de um investimento", + Description = "Efetua o saque total e aplica impostos sobre os ganhos conforme regras de idade do investimento.", + OperationId = "WithdrawInvestment", + Tags = new[] { "Investments" } + )] + [SwaggerResponse(StatusCodes.Status204NoContent, "Saque realizado com sucesso")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Regras de data inválidas ou investimento já retirado")] + [SwaggerResponse(StatusCodes.Status401Unauthorized, "Usuário não autenticado")] + public async Task WithdrawInvestment( + Guid id, + [FromBody, SwaggerRequestBody("Data de retirada do investimento")] WithdrawInvestmentInput input) + { + var userId = User.UserId(); + if (string.IsNullOrEmpty(userId)) + return BadRequest("Usuário não autenticado."); + + try + { + await _investmentService.WithdrawInvestmentAsync(id, userId, input.WithdrawalDate); + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/api/Program.cs b/api/Program.cs new file mode 100644 index 000000000..b26c9d611 --- /dev/null +++ b/api/Program.cs @@ -0,0 +1,38 @@ +using api.Common.Api; +using infra.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.AddConfiguration(); +builder.AddSecurity(); +builder.AddDataContexts(); +builder.AddDocumentation(); +builder.AddServices(); + + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1"); +}); + + +app.UseHttpsRedirection(); + +app.MapGroup("/api/Identity") + .WithTags("Identity") + .MapIdentityApi(); + +app.MapControllers(); + +app.Run(); diff --git a/api/Properties/launchSettings.json b/api/Properties/launchSettings.json new file mode 100644 index 000000000..c49ce4ad1 --- /dev/null +++ b/api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:1265", + "sslPort": 44308 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7271;http://localhost:5285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/api/api.csproj b/api/api.csproj new file mode 100644 index 000000000..a8ff3d5bd --- /dev/null +++ b/api/api.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + f8d8e878-4619-471e-b6ae-b93876ca851e + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + $(NoWarn);1591 + + + \ No newline at end of file diff --git a/api/api.http b/api/api.http new file mode 100644 index 000000000..ac6b35a47 --- /dev/null +++ b/api/api.http @@ -0,0 +1,6 @@ +@api_HostAddress = http://localhost:5285 + +GET {{api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/api/appsettings.json b/api/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/application/DTOs/CreateInvestmentInput.cs b/application/DTOs/CreateInvestmentInput.cs new file mode 100644 index 000000000..2c728d61e --- /dev/null +++ b/application/DTOs/CreateInvestmentInput.cs @@ -0,0 +1,14 @@ +namespace application.DTOs +{ + public class CreateInvestmentInput + { + public string OwnerId { get; private set; } = string.Empty; + public decimal Amount { get; set; } + public DateTime CreationDate { get; set; } + + public void SetOwner(string ownerId) + { + OwnerId = ownerId; + } + } +} \ No newline at end of file diff --git a/application/DTOs/InvestmentDto.cs b/application/DTOs/InvestmentDto.cs new file mode 100644 index 000000000..da2291899 --- /dev/null +++ b/application/DTOs/InvestmentDto.cs @@ -0,0 +1,14 @@ +namespace application.DTOs +{ + public class InvestmentDto + { + public Guid Id { get; set; } + public string OwnerId { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } + public decimal InitialAmount { get; set; } + public DateTime? WithdrawalDate { get; set; } + public decimal ExpectedBalance { get; set; } + public decimal? NetWithdrawAmount { get; set; } + public bool IsWithdrawn => WithdrawalDate.HasValue; + } +} \ No newline at end of file diff --git a/application/DTOs/PaginatedResult.cs b/application/DTOs/PaginatedResult.cs new file mode 100644 index 000000000..7e9b673c7 --- /dev/null +++ b/application/DTOs/PaginatedResult.cs @@ -0,0 +1,11 @@ +namespace application.DTOs +{ + public class PaginatedResult + { + public List Items { get; set; } = new(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public long TotalCount { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + } +} \ No newline at end of file diff --git a/application/DTOs/WithdrawInvestmentInput.cs b/application/DTOs/WithdrawInvestmentInput.cs new file mode 100644 index 000000000..20e1854fb --- /dev/null +++ b/application/DTOs/WithdrawInvestmentInput.cs @@ -0,0 +1,7 @@ +namespace application.DTOs +{ + public class WithdrawInvestmentInput + { + public DateTime WithdrawalDate { get; set; } + } +} \ No newline at end of file diff --git a/application/Services/IInvestmentService.cs b/application/Services/IInvestmentService.cs new file mode 100644 index 000000000..6bdd10c3f --- /dev/null +++ b/application/Services/IInvestmentService.cs @@ -0,0 +1,14 @@ +using application.DTOs; +using core.Entities; +using core.Filters; + +namespace application.Services +{ + public interface IInvestmentService + { + Task CreateInvestmentAsync(CreateInvestmentInput input); + Task WithdrawInvestmentAsync(Guid investmentId, string ownerId, DateTime withdrawalDate); + Task GetInvestmentByIdAsync(InvestmentFilter filter); + Task> GetInvestmentsByOwnerAsync(InvestmentFilter filter); + } +} \ No newline at end of file diff --git a/application/Services/InvestmentService.cs b/application/Services/InvestmentService.cs new file mode 100644 index 000000000..339e3a91d --- /dev/null +++ b/application/Services/InvestmentService.cs @@ -0,0 +1,133 @@ +using core.Entities; +using core.Repositories; +using core.Services; +using application.DTOs; +using common.TypeExtentions; +using core.Filters; + +namespace application.Services; + +public class InvestmentService : IInvestmentService +{ + private readonly IInvestmentRepository _repository; + private readonly IGainCalculationService _gainService; + private readonly ITaxCalculationService _taxService; + + public InvestmentService( + IInvestmentRepository repository, + IGainCalculationService gainService, + ITaxCalculationService taxService) + { + _repository = repository; + _gainService = gainService; + _taxService = taxService; + } + + public async Task CreateInvestmentAsync(CreateInvestmentInput input) + { + ValidateCreateInput(input.OwnerId, input.Amount, input.CreationDate); + + var investment = new Investment + { + Id = Guid.NewGuid(), + OwnerId = input.OwnerId, + InitialAmount = input.Amount, + CreationDate = input.CreationDate.ToUniversalTime() + }; + + await _repository.AddAsync(investment); + return MapToDtoAsync(investment); + } + + public async Task WithdrawInvestmentAsync(Guid investmentId, string ownerId, DateTime withdrawalDate) + { + withdrawalDate = withdrawalDate.ToUniversalTime(); + var investment = await _repository.GetByIdAsync(new(ownerId) { Id = investmentId }) ?? throw new InvalidOperationException("Investimento não encontrado."); + if (investment.IsWithdrawn) + throw new InvalidOperationException("Investimento já foi resgatado."); + + if (withdrawalDate.IsDateLessThan(investment.CreationDate)) + throw new ArgumentException("A data de resgate não pode ser anterior à data de criação."); + if (withdrawalDate.IsDateGreaterThan(DateTime.Today)) + throw new ArgumentException("A data de resgate não pode ser futura."); + + investment.WithdrawalDate = withdrawalDate; + await _repository.UpdateAsync(investment); + } + + public async Task GetInvestmentByIdAsync(InvestmentFilter filter) + { + var investment = await _repository.GetByIdAsync(filter); + return investment == null ? null : MapToDtoAsync(investment); + } + + public async Task> GetInvestmentsByOwnerAsync(InvestmentFilter filter) + { + if (string.IsNullOrWhiteSpace(filter.OwnerId)) + throw new ArgumentException("OwnerId é obrigatório."); + + var investments = await _repository.GetByOwnerIdAsync(filter); + var totalCount = await _repository.CountByOwnerIdAsync(filter); + + var items = new List(); + foreach (var investment in investments) + { + items.Add(MapToDtoAsync(investment)); + } + + return new PaginatedResult + { + Items = items, + PageNumber = filter.Page, + PageSize = filter.PageSizeClamped, + TotalCount = totalCount + }; + } + + private static void ValidateCreateInput(string ownerId, decimal amount, DateTime creationDate) + { + if (string.IsNullOrWhiteSpace(ownerId)) + throw new ArgumentException("OwnerId é obrigatório."); + if (amount <= 0) + throw new ArgumentException("O valor do investimento deve ser maior que zero."); + if (creationDate.IsDateGreaterThan(DateTime.Today)) + throw new ArgumentException("A data de criação não pode ser futura."); + } + + private InvestmentDto MapToDtoAsync(Investment investment) + { + DateTime calculationDate = investment.IsWithdrawn + ? investment.WithdrawalDate!.Value.ToUniversalTime() + : DateTime.Today.ToUniversalTime(); + + decimal gains = _gainService.CalculateGains( + investment.InitialAmount, + investment.CreationDate, + calculationDate + ); + + decimal expectedBalance = investment.InitialAmount + gains; + decimal? netWithdrawAmount = null; + + if (investment.IsWithdrawn) + { + decimal tax = _taxService.CalculateTax( + gains, + investment.CreationDate, + investment.WithdrawalDate!.Value + ); + netWithdrawAmount = expectedBalance - tax; + } + + return new InvestmentDto + { + Id = investment.Id, + OwnerId = investment.OwnerId, + CreationDate = investment.CreationDate, + InitialAmount = investment.InitialAmount, + WithdrawalDate = investment.WithdrawalDate, + ExpectedBalance = Math.Round(expectedBalance, 2), + NetWithdrawAmount = netWithdrawAmount.HasValue ? Math.Round(netWithdrawAmount.Value, 2) : null + }; + } +} \ No newline at end of file diff --git a/application/application.csproj b/application/application.csproj new file mode 100644 index 000000000..496120e58 --- /dev/null +++ b/application/application.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/common/TypeExtentions/ClaimsPrincipalExtension.cs b/common/TypeExtentions/ClaimsPrincipalExtension.cs new file mode 100644 index 000000000..cf377888c --- /dev/null +++ b/common/TypeExtentions/ClaimsPrincipalExtension.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; + +namespace common.TypeExtentions +{ + public static class ClaimsPrincipalExtension + { + public static string? UserId(this ClaimsPrincipal principal) + { + return principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + + } +} \ No newline at end of file diff --git a/common/TypeExtentions/DateTimeExtensions.cs b/common/TypeExtentions/DateTimeExtensions.cs new file mode 100644 index 000000000..b52a5992d --- /dev/null +++ b/common/TypeExtentions/DateTimeExtensions.cs @@ -0,0 +1,15 @@ +namespace common.TypeExtentions +{ + public static class DateTimeExtensions + { + public static bool IsDateGreaterThan(this DateTime leftSide, DateTime rightSide) + { + return leftSide.Date > rightSide.Date; + } + + public static bool IsDateLessThan(this DateTime leftSide, DateTime rightSide) + { + return leftSide.Date < rightSide.Date; + } + } +} \ No newline at end of file diff --git a/common/common.csproj b/common/common.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/common/common.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/core/Entities/Investment.cs b/core/Entities/Investment.cs new file mode 100644 index 000000000..25530bd7e --- /dev/null +++ b/core/Entities/Investment.cs @@ -0,0 +1,14 @@ +namespace core.Entities +{ + public class Investment + { + public Guid Id { get; set; } + public string OwnerId { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } + public decimal InitialAmount { get; set; } + public DateTime? WithdrawalDate { get; set; } + + public bool IsWithdrawn => WithdrawalDate.HasValue; + + } +} \ No newline at end of file diff --git a/core/Filters/InvestmentFilter.cs b/core/Filters/InvestmentFilter.cs new file mode 100644 index 000000000..6451c8a71 --- /dev/null +++ b/core/Filters/InvestmentFilter.cs @@ -0,0 +1,25 @@ +namespace core.Filters +{ + public class InvestmentFilter + { + public string OwnerId { get; private set; } = string.Empty; + public Guid? Id { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 10; + + + public int Skip => (Math.Max(1, Page) - 1) * PageSizeClamped; + public int PageSizeClamped => Math.Clamp(PageSize, 1, 100); + + public InvestmentFilter(string ownerId) + { + OwnerId = ownerId; + } + + public void SetOwner(string ownerId) + { + OwnerId = ownerId; + } + + } +} \ No newline at end of file diff --git a/core/Repositories/IInvestmentRepository.cs b/core/Repositories/IInvestmentRepository.cs new file mode 100644 index 000000000..f7fd4db86 --- /dev/null +++ b/core/Repositories/IInvestmentRepository.cs @@ -0,0 +1,16 @@ +using core.Entities; +using core.Filters; + +namespace core.Repositories +{ + public interface IInvestmentRepository + { + Task GetByIdAsync(InvestmentFilter filter); + Task> GetByOwnerIdAsync(InvestmentFilter filter); + Task CountByOwnerIdAsync(InvestmentFilter filter); + Task AddAsync(Investment investment); + Task UpdateAsync(Investment investment); + Task ExistsAsync(InvestmentFilter filter); + + } +} \ No newline at end of file diff --git a/core/Services/GainCalculationService.cs b/core/Services/GainCalculationService.cs new file mode 100644 index 000000000..3b1cab040 --- /dev/null +++ b/core/Services/GainCalculationService.cs @@ -0,0 +1,30 @@ +namespace core.Services +{ + public class GainCalculationService : IGainCalculationService + { + public decimal CalculateGains(decimal initialAmount, DateTime creationDate, DateTime untilDate) + { + if (untilDate < creationDate || initialAmount <= 0) + return 0; + + var months = GetFullMonthsBetween(creationDate, untilDate); + var balance = initialAmount; + + for (int i = 0; i < months; i++) + { + balance *= 1.0052m; // juros compostos de 0.52% ao mês + } + + var gains = balance - initialAmount; + return Math.Round(gains, 2); + } + + private static int GetFullMonthsBetween(DateTime start, DateTime end) + { + int months = (end.Year - start.Year) * 12 + (end.Month - start.Month); + if (end.Day < start.Day) + months--; + return Math.Max(0, months); + } + } +} \ No newline at end of file diff --git a/core/Services/IGainCalculationService.cs b/core/Services/IGainCalculationService.cs new file mode 100644 index 000000000..7de841f75 --- /dev/null +++ b/core/Services/IGainCalculationService.cs @@ -0,0 +1,7 @@ +namespace core.Services +{ + public interface IGainCalculationService + { + decimal CalculateGains(decimal initialAmount, DateTime creationDate, DateTime untilDate); + } +} \ No newline at end of file diff --git a/core/Services/ITaxCalculationService.cs b/core/Services/ITaxCalculationService.cs new file mode 100644 index 000000000..5dc6d0da2 --- /dev/null +++ b/core/Services/ITaxCalculationService.cs @@ -0,0 +1,7 @@ +namespace core.Services +{ + public interface ITaxCalculationService + { + decimal CalculateTax(decimal gain, DateTime creationDate, DateTime withdrawalDate); + } +} \ No newline at end of file diff --git a/core/Services/TaxCalculationService.cs b/core/Services/TaxCalculationService.cs new file mode 100644 index 000000000..92c6f2094 --- /dev/null +++ b/core/Services/TaxCalculationService.cs @@ -0,0 +1,22 @@ +namespace core.Services +{ + public class TaxCalculationService : ITaxCalculationService + { + public decimal CalculateTax(decimal gain, DateTime creationDate, DateTime withdrawalDate) + { + if (gain <= 0) + return 0; + + var ageInYears = Math.Round((withdrawalDate - creationDate).TotalDays / 365.25); + + decimal taxRate = ageInYears switch + { + < 1 => 0.225m, + < 2 => 0.185m, + _ => 0.15m + }; + + return Math.Round(gain * taxRate, 2); + } + } +} \ No newline at end of file diff --git a/core/core.csproj b/core/core.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/core/core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/infra/Data/ApplicationDbContext.cs b/infra/Data/ApplicationDbContext.cs new file mode 100644 index 000000000..607780cf4 --- /dev/null +++ b/infra/Data/ApplicationDbContext.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using core.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace infra.Data +{ + public class ApplicationDbContext : IdentityDbContext + { + public DbSet Investments { get; set; } + + public ApplicationDbContext(DbContextOptions options) : + base(options) + { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + base.OnModelCreating(builder); + } + + } +} \ No newline at end of file diff --git a/infra/Data/Mapping/InvestmentMapping.cs b/infra/Data/Mapping/InvestmentMapping.cs new file mode 100644 index 000000000..5c6632583 --- /dev/null +++ b/infra/Data/Mapping/InvestmentMapping.cs @@ -0,0 +1,36 @@ +using core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace infra.Data.Mapping +{ + public class InvestmentMapping : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Investment"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.OwnerId) + .IsRequired() + .HasColumnType("varchar(128)"); + + builder.Property(x => x.CreationDate) + .IsRequired() + .HasColumnType("timestamptz"); + + builder.Property(x => x.InitialAmount) + .IsRequired() + .HasColumnType("decimal(18,2)"); + + + builder.Property(x => x.WithdrawalDate) + .HasColumnType("timestamptz"); + + + builder.HasIndex(x => x.OwnerId); + + } + } +} \ No newline at end of file diff --git a/infra/Migrations/20251128033107_InitialMigration.Designer.cs b/infra/Migrations/20251128033107_InitialMigration.Designer.cs new file mode 100644 index 000000000..db2c912aa --- /dev/null +++ b/infra/Migrations/20251128033107_InitialMigration.Designer.cs @@ -0,0 +1,303 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using infra.Data; + +#nullable disable + +namespace infra.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251128033107_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("core.Entities.Investment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamptz"); + + b.Property("InitialAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("WithdrawalDate") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Investment", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infra/Migrations/20251128033107_InitialMigration.cs b/infra/Migrations/20251128033107_InitialMigration.cs new file mode 100644 index 000000000..f7819588f --- /dev/null +++ b/infra/Migrations/20251128033107_InitialMigration.cs @@ -0,0 +1,246 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace infra.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Investment", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerId = table.Column(type: "varchar(128)", nullable: false), + CreationDate = table.Column(type: "timestamptz", nullable: false), + InitialAmount = table.Column(type: "numeric(18,2)", nullable: false), + WithdrawalDate = table.Column(type: "timestamptz", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Investment", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Investment_OwnerId", + table: "Investment", + column: "OwnerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "Investment"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/infra/Migrations/ApplicationDbContextModelSnapshot.cs b/infra/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 000000000..e3e9293e5 --- /dev/null +++ b/infra/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,300 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using infra.Data; + +#nullable disable + +namespace infra.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("core.Entities.Investment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamptz"); + + b.Property("InitialAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("WithdrawalDate") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Investment", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/infra/Repositories/InvestmentRepository.cs b/infra/Repositories/InvestmentRepository.cs new file mode 100644 index 000000000..0d8e9399a --- /dev/null +++ b/infra/Repositories/InvestmentRepository.cs @@ -0,0 +1,66 @@ +using core.Entities; +using core.Filters; +using core.Repositories; +using infra.Data; +using Microsoft.EntityFrameworkCore; + +namespace infra.Repositories +{ + public class InvestmentRepository : IInvestmentRepository + { + private readonly ApplicationDbContext _context; + + public InvestmentRepository(ApplicationDbContext context) + { + _context = context; + } + + public async Task AddAsync(Investment investment) + { + _context.Investments.Add(investment); + await _context.SaveChangesAsync(); + } + + public async Task ExistsAsync(InvestmentFilter filter) + { + return await _context.Investments.AnyAsync(e => e.Id == filter.Id && e.OwnerId == filter.OwnerId); + } + + public async Task GetByIdAsync(InvestmentFilter filter) + { + return await _context.Investments + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == filter.Id && e.OwnerId == filter.OwnerId); + } + + public async Task> GetByOwnerIdAsync(InvestmentFilter filter) + { + if (string.IsNullOrWhiteSpace(filter.OwnerId)) + throw new ArgumentException("OwnerId não pode ser nulo ou vazio."); + + return await _context.Investments + .AsNoTracking() + .Where(e => e.OwnerId == filter.OwnerId) + .OrderBy(e => e.CreationDate) + .Skip(filter.Skip) + .Take(filter.PageSizeClamped) + .ToListAsync(); + } + + public async Task CountByOwnerIdAsync(InvestmentFilter filter) + { + if (string.IsNullOrWhiteSpace(filter.OwnerId)) + throw new ArgumentException("OwnerId não pode ser nulo ou vazio."); + + return await _context.Investments + .AsNoTracking() + .LongCountAsync(e => e.OwnerId == filter.OwnerId); + } + + public async Task UpdateAsync(Investment investment) + { + _context.Investments.Update(investment); + await _context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/infra/infra.csproj b/infra/infra.csproj new file mode 100644 index 000000000..139d61b3d --- /dev/null +++ b/infra/infra.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + \ No newline at end of file diff --git a/solucao.sln b/solucao.sln new file mode 100644 index 000000000..73b4b0087 --- /dev/null +++ b/solucao.sln @@ -0,0 +1,52 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{E4E78CE6-C619-4626-8F39-E4E8135F8293}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "common", "common\common.csproj", "{131B7BBF-94C4-4B7C-8927-A235F16D4E87}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "core", "core\core.csproj", "{B19FFD92-8123-4C0E-A81A-528A5F433E59}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "infra", "infra\infra.csproj", "{AD8308B3-4A67-45BC-937F-259B4639B10A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "application", "application\application.csproj", "{D05E4524-B912-4FF2-B8B4-EBB14053123A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "unitTest", "unitTest\unitTest.csproj", "{F5C08DD3-6729-4123-A64A-1E3D32C76207}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E4E78CE6-C619-4626-8F39-E4E8135F8293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4E78CE6-C619-4626-8F39-E4E8135F8293}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4E78CE6-C619-4626-8F39-E4E8135F8293}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4E78CE6-C619-4626-8F39-E4E8135F8293}.Release|Any CPU.Build.0 = Release|Any CPU + {131B7BBF-94C4-4B7C-8927-A235F16D4E87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {131B7BBF-94C4-4B7C-8927-A235F16D4E87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {131B7BBF-94C4-4B7C-8927-A235F16D4E87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {131B7BBF-94C4-4B7C-8927-A235F16D4E87}.Release|Any CPU.Build.0 = Release|Any CPU + {B19FFD92-8123-4C0E-A81A-528A5F433E59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B19FFD92-8123-4C0E-A81A-528A5F433E59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B19FFD92-8123-4C0E-A81A-528A5F433E59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B19FFD92-8123-4C0E-A81A-528A5F433E59}.Release|Any CPU.Build.0 = Release|Any CPU + {AD8308B3-4A67-45BC-937F-259B4639B10A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD8308B3-4A67-45BC-937F-259B4639B10A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD8308B3-4A67-45BC-937F-259B4639B10A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD8308B3-4A67-45BC-937F-259B4639B10A}.Release|Any CPU.Build.0 = Release|Any CPU + {D05E4524-B912-4FF2-B8B4-EBB14053123A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D05E4524-B912-4FF2-B8B4-EBB14053123A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D05E4524-B912-4FF2-B8B4-EBB14053123A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D05E4524-B912-4FF2-B8B4-EBB14053123A}.Release|Any CPU.Build.0 = Release|Any CPU + {F5C08DD3-6729-4123-A64A-1E3D32C76207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5C08DD3-6729-4123-A64A-1E3D32C76207}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5C08DD3-6729-4123-A64A-1E3D32C76207}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5C08DD3-6729-4123-A64A-1E3D32C76207}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/unitTest/GlobalUsings.cs b/unitTest/GlobalUsings.cs new file mode 100644 index 000000000..cefced496 --- /dev/null +++ b/unitTest/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/unitTest/Tests/TestGainCalculationService.cs b/unitTest/Tests/TestGainCalculationService.cs new file mode 100644 index 000000000..54c731604 --- /dev/null +++ b/unitTest/Tests/TestGainCalculationService.cs @@ -0,0 +1,94 @@ +using core.Services; + +namespace unitTest.Tests +{ + public class TestGainCalculationService + { + private GainCalculationService _service; + + [SetUp] + public void Setup() + { + _service = new GainCalculationService(); + } + + [Test] + public void CalculateGains_ReturnsZero_WhenInitialAmountIsZeroOrNegative() + { + var result1 = _service.CalculateGains(0, DateTime.Now, DateTime.Now.AddMonths(5)); + var result2 = _service.CalculateGains(-100, DateTime.Now, DateTime.Now.AddMonths(5)); + + Assert.That(result1, Is.EqualTo(0)); + Assert.That(result2, Is.EqualTo(0)); + } + + [Test] + public void CalculateGains_ReturnsZero_WhenUntilDateIsBeforeCreationDate() + { + var result = _service.CalculateGains(1000, new DateTime(2024, 5, 10), new DateTime(2024, 5, 5)); + + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void CalculateGains_ReturnsZero_WhenNoFullMonthsHavePassed() + { + var result = _service.CalculateGains( + 1000, + new DateTime(2024, 5, 10), + new DateTime(2024, 5, 30) + ); + + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void CalculateGains_ReturnsCorrectValue_ForOneFullMonth() + { + var result = _service.CalculateGains( + 1000, + new DateTime(2024, 1, 10), + new DateTime(2024, 2, 10) + ); + + Assert.That(result, Is.EqualTo(5.20m)); + } + + [Test] + public void CalculateGains_ReturnsCorrectValue_ForMultipleMonths() + { + var result = _service.CalculateGains( + 1000, + new DateTime(2024, 1, 10), + new DateTime(2024, 4, 10) + ); + + Assert.That(result, Is.EqualTo(15.68m)); + } + + [Test] + public void CalculateGains_HandlesEndDateEarlierInMonthCorrectly() + { + var result = _service.CalculateGains( + 1000, + new DateTime(2024, 1, 15), + new DateTime(2024, 2, 14) + ); + + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void CalculateGains_HandlesExactMonthBoundary() + { + var result = _service.CalculateGains( + 2000, + new DateTime(2024, 2, 20), + new DateTime(2024, 3, 20) + ); + + + Assert.That(result, Is.EqualTo(10.40m)); + } + } +} diff --git a/unitTest/Tests/TestInvestmentService.cs b/unitTest/Tests/TestInvestmentService.cs new file mode 100644 index 000000000..93b17ae19 --- /dev/null +++ b/unitTest/Tests/TestInvestmentService.cs @@ -0,0 +1,226 @@ +using application.DTOs; +using application.Services; +using core.Entities; +using core.Filters; +using core.Repositories; +using core.Services; +using Moq; + +namespace unitTest.Tests +{ + public class TestInvestmentService + { + private Mock _repoMock = null!; + private IGainCalculationService _gainMock = null!; + private ITaxCalculationService _taxMock = null!; + private InvestmentService _service = null!; + + [SetUp] + public void Setup() + { + _repoMock = new Mock(); + _gainMock = new GainCalculationService(); + _taxMock = new TaxCalculationService(); + + _service = new InvestmentService( + _repoMock.Object, + _gainMock, + _taxMock + ); + } + + + [Test] + public async Task CreateInvestmentAsync_ShouldCreateCorrectly() + { + var input = new CreateInvestmentInput + { + Amount = 1000, + CreationDate = DateTime.Today + }; + input.SetOwner("user-123"); + + Investment? savedInvestment = null!; + _repoMock.Setup(r => r.AddAsync(It.IsAny())) + .Callback(inv => savedInvestment = inv) + .Returns(Task.CompletedTask); + + var result = await _service.CreateInvestmentAsync(input); + + Assert.That(savedInvestment, Is.Not.Null); + Assert.That(savedInvestment.OwnerId, Is.EqualTo("user-123")); + Assert.That(savedInvestment.InitialAmount, Is.EqualTo(1000)); + Assert.That(result.InitialAmount, Is.EqualTo(1000)); + } + + [Test] + public void CreateInvestmentAsync_ShouldThrow_WhenOwnerIdMissing() + { + var input = new CreateInvestmentInput + { + Amount = 1000, + CreationDate = DateTime.Today + }; + + Assert.ThrowsAsync(() => _service.CreateInvestmentAsync(input)); + } + + + [Test] + public async Task WithdrawInvestmentAsync_ShouldUpdateWithdrawalDate() + { + var id = Guid.NewGuid(); + var investment = new Investment + { + Id = id, + OwnerId = "user-1", + CreationDate = DateTime.Today.AddDays(-10), + InitialAmount = 500 + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(investment); + + _repoMock.Setup(r => r.UpdateAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var withdrawalDate = DateTime.Today; + + await _service.WithdrawInvestmentAsync(id, "user-1", withdrawalDate); + + Assert.That(investment.WithdrawalDate, Is.Not.Null); + Assert.That(investment.WithdrawalDate!.Value.Date, Is.EqualTo(withdrawalDate.Date)); + } + + [Test] + public void WithdrawInvestmentAsync_ShouldThrow_WhenInvestmentNotFound() + { + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync((Investment?)null); + + Assert.ThrowsAsync(() => + _service.WithdrawInvestmentAsync(Guid.NewGuid(), "user-1", DateTime.Today)); + } + + [Test] + public void WithdrawInvestmentAsync_ShouldThrow_WhenAlreadyWithdrawn() + { + var investment = new Investment + { + Id = Guid.NewGuid(), + OwnerId = "user-1", + CreationDate = DateTime.Today.AddDays(-10), + WithdrawalDate = DateTime.Today + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(investment); + + Assert.ThrowsAsync(() => + _service.WithdrawInvestmentAsync(investment.Id, "user-1", DateTime.Today)); + } + + [Test] + public void WithdrawInvestmentAsync_ShouldThrow_WhenWithdrawalDateBeforeCreation() + { + var inv = new Investment + { + Id = Guid.NewGuid(), + OwnerId = "user-1", + CreationDate = DateTime.Today, + InitialAmount = 100 + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(inv); + + Assert.ThrowsAsync(() => + _service.WithdrawInvestmentAsync(inv.Id, "user-1", DateTime.Today.AddDays(-1))); + } + + [Test] + public void WithdrawInvestmentAsync_ShouldThrow_WhenWithdrawalDateInFuture() + { + var inv = new Investment + { + Id = Guid.NewGuid(), + OwnerId = "user-1", + CreationDate = DateTime.Today.AddDays(-5), + InitialAmount = 100 + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(inv); + + Assert.ThrowsAsync(() => + _service.WithdrawInvestmentAsync(inv.Id, "user-1", DateTime.Today.AddDays(1))); + } + + + [Test] + public async Task GetInvestmentByIdAsync_ShouldReturnDto() + { + var id = Guid.NewGuid(); + var inv = new Investment + { + Id = id, + OwnerId = "user-1", + CreationDate = DateTime.Today, + InitialAmount = 100 + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(inv); + + + var result = await _service.GetInvestmentByIdAsync(new InvestmentFilter("user-1"){ Id = id}); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.ExpectedBalance, Is.EqualTo(100)); + } + + + [Test] + public async Task GetInvestmentsByOwnerAsync_ShouldReturnPaginatedResult() + { + var invList = new List + { + new Investment { Id = Guid.NewGuid(), OwnerId = "user-1", InitialAmount = 100, CreationDate = DateTime.Today } + }; + + _repoMock.Setup(r => r.GetByOwnerIdAsync(It.IsAny())) + .ReturnsAsync(invList); + + _repoMock.Setup(r => r.CountByOwnerIdAsync(It.IsAny())) + .ReturnsAsync(1); + + + var result = await _service.GetInvestmentsByOwnerAsync(new InvestmentFilter("user-1")); + + Assert.That(result.Items.Count, Is.EqualTo(1)); + Assert.That(result.TotalCount, Is.EqualTo(1)); + Assert.That(result.Items[0].ExpectedBalance, Is.EqualTo(100)); + } + + [Test] + public async Task MapToDtoAsync_ShouldCalculateGainsAndTaxes() + { + var inv = new Investment + { + Id = Guid.NewGuid(), + OwnerId = "u1", + InitialAmount = 1000, + CreationDate = DateTime.Today.AddYears(-2), + WithdrawalDate = DateTime.Today + }; + + _repoMock.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(inv); + + var result = await _service.GetInvestmentByIdAsync(new InvestmentFilter("u1")); + + Assert.That(result!.ExpectedBalance, Is.EqualTo(1132.56m)); + Assert.That(result.NetWithdrawAmount, Is.EqualTo(1112.68m)); + } + } +} diff --git a/unitTest/Tests/TestTaxCalculationService.cs b/unitTest/Tests/TestTaxCalculationService.cs new file mode 100644 index 000000000..7f5d6105c --- /dev/null +++ b/unitTest/Tests/TestTaxCalculationService.cs @@ -0,0 +1,88 @@ +using core.Services; + +namespace unitTest.Tests +{ + public class TestTaxCalculationService + { + private TaxCalculationService _service; + + [SetUp] + public void Setup() + { + _service = new TaxCalculationService(); + } + + [Test] + public void CalculateTax_ReturnsZero_WhenGainIsZeroOrNegative() + { + Assert.That(_service.CalculateTax(0, DateTime.Now.AddYears(-1), DateTime.Now), Is.EqualTo(0)); + Assert.That(_service.CalculateTax(-50, DateTime.Now.AddYears(-1), DateTime.Now), Is.EqualTo(0)); + } + + [Test] + public void CalculateTax_Uses_22_5_Percent_WhenAgeIsLessThanOneYear() + { + var creation = new DateTime(2024, 1, 10); + var withdrawal = new DateTime(2024, 6, 10); + + var result = _service.CalculateTax(200m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(45.00m)); + } + + [Test] + public void CalculateTax_Uses_18_5_Percent_WhenAgeIsBetweenOneAndTwoYears() + { + var creation = new DateTime(2023, 1, 10); + var withdrawal = new DateTime(2024, 6, 10); + + var result = _service.CalculateTax(200m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(37.00m)); + } + + [Test] + public void CalculateTax_Uses_15_Percent_WhenAgeIsTwoYearsOrMore() + { + var creation = new DateTime(2022, 1, 10); + var withdrawal = new DateTime(2024, 6, 10); + + var result = _service.CalculateTax(200m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(30.00m)); + } + + [Test] + public void CalculateTax_HandlesBoundaryAtExactlyOneYear() + { + var creation = new DateTime(2023, 1, 1); + var withdrawal = new DateTime(2024, 1, 1); + + var result = _service.CalculateTax(100m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(18.50m)); + } + + [Test] + public void CalculateTax_HandlesBoundaryAtExactlyTwoYears() + { + var creation = new DateTime(2022, 1, 1); + var withdrawal = new DateTime(2024, 1, 1); + + var result = _service.CalculateTax(100m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(15.00m)); + } + + [Test] + public void CalculateTax_RoundsToTwoDecimalPlaces() + { + var creation = new DateTime(2024, 1, 10); + var withdrawal = new DateTime(2024, 6, 10); + + var result = _service.CalculateTax(123.456m, creation, withdrawal); + + Assert.That(result, Is.EqualTo(Math.Round(123.456m * 0.225m, 2))); + } + } +} diff --git a/unitTest/unitTest.csproj b/unitTest/unitTest.csproj new file mode 100644 index 000000000..43489598b --- /dev/null +++ b/unitTest/unitTest.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + +