diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..aada8650 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +################################################################################ +# This .gitignore file was automatically created by Microsoft(R) Visual Studio. +################################################################################ + +/.vs +/InvestmentPerformanceWebApi/.vs/InvestmentPerformanceWebApi +/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/bin/Debug/net8.0 +/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/obj +/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/bin/Debug/net8.0 +/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/obj +/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/app.db +/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/app.db-shm +/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/app.db-wal diff --git a/InvestmentPerformanceWebAPI.md b/InvestmentPerformanceWebAPI.md index 2e96afac..98fec455 100644 --- a/InvestmentPerformanceWebAPI.md +++ b/InvestmentPerformanceWebAPI.md @@ -3,12 +3,16 @@ ## General instructions: Please fork this project and submit a pull request when completed. You should submit a working piece of software that is tested and can be run. We will review your pull request and execute your code, please provide instructions on how to do so. -Please keep in mind that this exercise is intended to be achievable in a couple of hours. We expect a production ready submission that demonstrates not only the code you write but quality controls such as exception handling, logging, unit tests, etc. Assume that this api will be part of a larger system. If there are larger considerations, that would have affected decisions of what is in/out of scope, please make note of your assumptions. The majority of the tech stack in use at Nuix Discover is SQLServer, C#.NET, ExtJs, Vue.js. We prefer the use of that stack, but we want you to showcase your abilities. If you don't know those specific technologies, you can substitute. +Please keep in mind that this exercise is intended to be achievable in a couple of hours. We expect a production ready submission that demonstrates not only the code you write but quality controls such as exception handling, +logging, unit tests, etc. Assume that this api will be part of a larger system. If there are larger considerations, that would have affected decisions of what is in/out of scope, please make note of your assumptions. +The majority of the tech stack in use at Nuix Discover is SQLServer, C#.NET, ExtJs, Vue.js. We prefer the use of that stack, but we want you to showcase your abilities. If you don't know those specific technologies, +you can substitute. ## Problem statement -The company you are working for is building an investment trading platform for stock, bond and mutual funds. The platform will have various functionality to buy, sell, deposit, withdrawal, and report on investments. For this part of the product, they need you to create a web api to return data for investment performance. The type of api to create is your decision, but it must be hosted on a webserver and accessed via web request. +The company you are working for is building an investment trading platform for stock, bond and mutual funds. The platform will have various functionality to buy, sell, deposit, withdrawal, and report on investments. +For this part of the product, they need you to create a web api to return data for investment performance. The type of api to create is your decision, but it must be hosted on a webserver and accessed via web request. ## Problem details/user stories: - ### Get a list of current investments for the user diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ControllerTests/UserInvestmentTests.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ControllerTests/UserInvestmentTests.cs new file mode 100644 index 00000000..9bfd3447 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ControllerTests/UserInvestmentTests.cs @@ -0,0 +1,121 @@ +using Moq; +using AutoMapper; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.AspNetCore.Mvc; +using AutoFixture; +using InvestmentPerformanceWebApi.Api.Controllers; +using InvestmentPerformanceWebApi.Services.Interfaces; +using InvestmentPerformanceWebApi.Api.ViewModels.Investments; + +namespace InvestmentPerformanceApi.Tests.ControllerTests +{ + public class UserInvestmentsControllerTests + { + private readonly Mock _mockInvestmentService; + private readonly IMapper _mapper; + private readonly Mock> _mockLogger; + private readonly UserInvestmentsController _controller; + + public UserInvestmentsControllerTests() + { + _mockInvestmentService = new Mock(); + _mockLogger = new Mock>(); + + var configExpression = new MapperConfigurationExpression(); + configExpression.CreateMap(); + configExpression.CreateMap(); + + var mapperConfig = new MapperConfiguration(configExpression, NullLoggerFactory.Instance); + _mapper = mapperConfig.CreateMapper(); + + _controller = new UserInvestmentsController( + _mockLogger.Object, + _mockInvestmentService.Object, + _mapper + ); + } + + [Fact] + public async Task GetInvestmentsForUser_ReturnsOk_WithMappedDto() + { + // Arrange + int userId = 1; + var fixture = new Fixture(); + var investments = fixture.CreateMany(3); + + + // Use Task.FromResult to avoid Moq ReturnsAsync issues + _mockInvestmentService + .Setup(s => s.GetInvestmentsForUserAsync(userId)) + .Returns(Task.FromResult(investments)); + // Act + var result = await _controller.GetInvestmentsForUser(userId); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedDtos = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(3, ((List)returnedDtos).Count); + } + [Fact] + public async Task GetSingleInvestmentForUser_ReturnsOk_WithMappedDto() + { + // Arrange + int userId = 1; + + var fixture = new Fixture(); + + var investment = fixture.Create(); + + _mockInvestmentService + .Setup(s => s.GetInvestmentForUserAsync(userId, investment.Id)) + .Returns(Task.FromResult(investment)); + + // Act + var result = await _controller.GetInvestmentForUser(userId, investment.Id); + + // Assert + var okResult = Assert.IsType(result.Result); + + var dto = Assert.IsType(okResult.Value); + + Assert.Equal(investment.Id, dto.Id); + Assert.Equal(investment.Name, dto.Name); + } + + + [Fact] + public async Task GetInvestmentsForUser_ReturnsNotFound_WhenInvestmentDoesNotExist() + { + // Arrange + + _mockInvestmentService + .Setup(s => s.GetInvestmentsForUserAsync(It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("Investment not found")); + + // Act + var result = await _controller.GetInvestmentsForUser(1); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + + Assert.Contains("Investment not found", notFoundResult.Value.ToString()); + } + + [Fact] + public async Task GetSingleInvestmentsForUser_ReturnsNotFound_WhenInvestmentDoesNotExist() + { + // Arrange + _mockInvestmentService + .Setup(s => s.GetInvestmentForUserAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("Investment not found")); + + // Act + var result = await _controller.GetInvestmentForUser(1, 2); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("Investment not found", notFoundResult.Value.ToString()); + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/DomainTests/InvestmentTests.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/DomainTests/InvestmentTests.cs new file mode 100644 index 00000000..8cf0a5a2 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/DomainTests/InvestmentTests.cs @@ -0,0 +1,111 @@ +using System; +using InvestmentPerformanceWebApi.Domain.Models; +using Xunit; + +namespace InvestmentPerformanceWebApi.Tests.Domain +{ + public class InvestmentTests + { + [Fact] + public void Constructor_WithNegativeShares_ThrowsArgumentException() + { + // Arrange + string name = "Test Investment"; + int id = 1; + int shares = -5; + decimal costBasis = 100m; + decimal currentPrice = 120m; + DateTime purchasedDate = DateTime.UtcNow; + + // Act & Assert + Assert.Throws(() => + new Investment(name, id, shares, costBasis, currentPrice, purchasedDate)); + } + + [Fact] + public void CurrentValue_ComputesCorrectly() + { + // Arrange + var investment = new Investment( + "Test", + 1, + 10, + 50m, + 60m, + DateTime.UtcNow.AddDays(-10)); + + // Act + var value = investment.CurrentValue; + + // Assert + Assert.Equal(10 * 60m, value); + } + + [Theory] + [InlineData(-10, "Short Term")] + [InlineData(-400, "Long Term")] + [InlineData(365, "Short Term")] + [InlineData(1, "Short Term")] + //[InlineData(100000000000, "Long Term")] //chokes on this, would need to adjust data rules for how long someone can hold an investment for + public void Term_ReturnsCorrectValue(int daysOffset, string expectedTerm) + { + // Arrange + var purchasedDate = DateTime.UtcNow.AddDays(daysOffset); + var investment = new Investment( + "Test", + 1, + 10, + 50m, + 60m, + purchasedDate); + + // Act + var term = investment.Term; + + // Assert + Assert.Equal(expectedTerm, term); + } + + [Fact] + public void TotalGainOrLoss_ComputesCorrectly() + { + // Arrange + var investment = new Investment( + "Test", + 1, + 10, + 50m, + 60m, + DateTime.UtcNow.AddDays(-10)); + + // Act + var totalGain = investment.TotalGainOrLoss; + + // Assert + Assert.Equal((10 * 60m) - (10 * 50m), totalGain); + } + + [Fact] + public void Constructor_InitializesPropertiesCorrectly() + { + // Arrange + string name = "Test Investment"; + int id = 1; + int shares = 10; + decimal costBasis = 50m; + decimal currentPrice = 60m; + DateTime purchasedDate = DateTime.UtcNow; + + // Act + var investment = new Investment(name, id, shares, costBasis, currentPrice, purchasedDate); + + // Assert + Assert.Equal(name, investment.Name); + Assert.Equal(id, investment.Id); + Assert.Equal(shares, investment.Shares); + Assert.Equal(costBasis, investment.CostBasis); + Assert.Equal(currentPrice, investment.CurrentPrice); + Assert.Equal(purchasedDate, investment.PurchasedUtc); + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/InvestmentPerformanceApi.Tests.csproj b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/InvestmentPerformanceApi.Tests.csproj new file mode 100644 index 00000000..ae7d9e5a --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/InvestmentPerformanceApi.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ServiceTests/InvestmentServiceTests.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ServiceTests/InvestmentServiceTests.cs new file mode 100644 index 00000000..e4cc4386 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceApi.Tests/ServiceTests/InvestmentServiceTests.cs @@ -0,0 +1,100 @@ +using InvestmentPerformanceWebApi.DbInfrastructure; +using InvestmentPerformanceWebApi.DbInfrastructure.Models; +using InvestmentPerformanceWebApi.Domain.Services; +using Microsoft.EntityFrameworkCore; + + +public class InvestmentServiceTests +{ + private AppDbContext GetInMemoryDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) // unique DB for each test + .Options; + + return new AppDbContext(options); + } + + [Fact] + public async Task GetInvestmentsForUserAsync_UserExists_ReturnsInvestments() + { + // Arrange + var context = GetInMemoryDbContext(); + + var user = new User { Id = 1, FirstName = "Alice", LastName = "Slim", UserName = "salice" }; + context.Users.Add(user); + context.Investments.Add(new Investment + { + Id = 10, + UserId = user.Id, + Name = "Stock A", + Shares = 5, + CostBasis = 100, + CurrentPrice = 120, + PurchasedAtUtc = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + var service = new InvestmentService(context); + + // Act + var investments = await service.GetInvestmentsForUserAsync(user.Id); + + // Assert + Assert.Single(investments); + Assert.Equal("Stock A", investments.First().Name); + } + + [Fact] + public async Task GetInvestmentsForUserAsync_UserDoesNotExist_ThrowsKeyNotFoundException() + { + // Arrange + var context = GetInMemoryDbContext(); + var service = new InvestmentService(context); + + // Act & Assert + await Assert.ThrowsAsync(() => service.GetInvestmentsForUserAsync(999)); + } + + [Fact] + public async Task GetInvestmentForUserAsync_InvestmentExists_ReturnsInvestment() + { + // Arrange + var context = GetInMemoryDbContext(); + + var user = new User { Id = 1, FirstName = "Alice", LastName = "Slim", UserName = "salice" }; + context.Users.Add(user); + var investment = new Investment + { + Id = 10, + UserId = user.Id, + Name = "Stock A", + Shares = 5, + CostBasis = 100, + CurrentPrice = 120, + PurchasedAtUtc = DateTime.UtcNow + }; + context.Investments.Add(investment); + await context.SaveChangesAsync(); + + var service = new InvestmentService(context); + + // Act + var result = await service.GetInvestmentForUserAsync(user.Id, investment.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(investment.Id, result.Id); + } + + [Fact] + public async Task GetInvestmentForUserAsync_InvestmentDoesNotExist_ThrowsKeyNotFoundException() + { + // Arrange + var context = GetInMemoryDbContext(); + var service = new InvestmentService(context); + + // Act & Assert + await Assert.ThrowsAsync(() => service.GetInvestmentForUserAsync(1, 999)); + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.sln b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.sln new file mode 100644 index 00000000..f599fd8f --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36811.4 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceWebApi", "InvestmentPerformanceWebApi\InvestmentPerformanceWebApi.csproj", "{42DE2698-83AE-4E5E-81BF-C919DDD7E84B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InvestmentPerformanceApi.Tests", "InvestmentPerformanceApi.Tests\InvestmentPerformanceApi.Tests.csproj", "{F237000A-0889-4FAD-AA52-734DA54D833D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {42DE2698-83AE-4E5E-81BF-C919DDD7E84B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42DE2698-83AE-4E5E-81BF-C919DDD7E84B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42DE2698-83AE-4E5E-81BF-C919DDD7E84B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42DE2698-83AE-4E5E-81BF-C919DDD7E84B}.Release|Any CPU.Build.0 = Release|Any CPU + {F237000A-0889-4FAD-AA52-734DA54D833D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F237000A-0889-4FAD-AA52-734DA54D833D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F237000A-0889-4FAD-AA52-734DA54D833D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F237000A-0889-4FAD-AA52-734DA54D833D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {56360198-CE0B-4098-96BC-C825BC9B957A} + EndGlobalSection +EndGlobal diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Controllers/UserInvestmentsController.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Controllers/UserInvestmentsController.cs new file mode 100644 index 00000000..1d805e88 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Controllers/UserInvestmentsController.cs @@ -0,0 +1,84 @@ +using InvestmentPerformanceWebApi.DbInfrastructure.Models; +using InvestmentPerformanceWebApi.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; +using AutoMapper; +using InvestmentPerformanceWebApi.Api.ViewModels.Investments; + +namespace InvestmentPerformanceWebApi.Api.Controllers +{ + [ApiController] + [Route("users/{userid}/investments")] + public class UserInvestmentsController : ControllerBase + { + private readonly ILogger _logger; + private readonly IMapper _mapper; + public IInvestmentService _investmentService; + + public UserInvestmentsController(ILogger logger, IInvestmentService investmentService, IMapper mapper) + { + _logger = logger; + _investmentService = investmentService; + _mapper = mapper; + } + + [HttpGet()] + public async Task>> GetInvestmentsForUser( + int userId) + { + + try + { + var domainInvestments = await _investmentService.GetInvestmentsForUserAsync(userId); + var summaryDtos = _mapper.Map>(domainInvestments); + + return Ok(summaryDtos); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, $"Resource not found for userId {userId}"); + return NotFound(new { message = ex.Message }); + + } + catch (Exception ex) + { + return HandleException(ex, userId); + } + + } + + [HttpGet("{investmentId}")] + public async Task> GetInvestmentForUser(int userId, int investmentId) + { + try + { + var domainInvestment = await _investmentService.GetInvestmentForUserAsync(userId, investmentId); + var detailedInvestmentDto = _mapper.Map(domainInvestment); + + return Ok(detailedInvestmentDto); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, $"Resource not found for userId: {userId} and investmentId: {investmentId}"); + return NotFound(new {message = ex.Message}); + + } + catch (Exception ex) + { + return HandleException(ex, userId); + + } + } + + private ActionResult HandleException(Exception ex, int userId) + { + var traceId = HttpContext.TraceIdentifier; + var controllerName = ControllerContext.ActionDescriptor.ControllerName; + var actionName = ControllerContext.ActionDescriptor.ActionName; + var endpointPath = HttpContext.Request.Path; + + _logger.LogError(ex, $"ERROR: {controllerName} - {actionName}. Error reference TraceId: {traceId}"); + return StatusCode(500, $"Error accessing {endpointPath} for UserId: {userId}. Reference TraceId: {traceId}"); + + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/InvestmentPerformanceWebApi.http b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/InvestmentPerformanceWebApi.http new file mode 100644 index 00000000..be8b5067 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/InvestmentPerformanceWebApi.http @@ -0,0 +1,6 @@ +@InvestmentPerformanceWebApi_HostAddress = http://localhost:5019 + +GET {{InvestmentPerformanceWebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Mappings/InvestmentMappingProfile.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Mappings/InvestmentMappingProfile.cs new file mode 100644 index 00000000..55ed6999 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Mappings/InvestmentMappingProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using InvestmentPerformanceWebApi.Api.ViewModels.Investments; +using InvestmentPerformanceWebApi.Domain.Models; + +namespace InvestmentPerformanceWebApi.Api.Mappings +{ + public class InvestmentMappingProfile : Profile + { + public InvestmentMappingProfile() + { + CreateMap(); + CreateMap(); + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Program.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Program.cs new file mode 100644 index 00000000..b8348589 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/Program.cs @@ -0,0 +1,49 @@ +using InvestmentPerformanceWebApi.DbInfrastructure; +using InvestmentPerformanceWebApi.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; +using InvestmentPerformanceWebApi.Domain.Services; +using InvestmentPerformanceWebApi.Api.Mappings; + +var builder = WebApplication.CreateBuilder(args); + + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add( + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=app.db")); +builder.Services.AddScoped(); + +builder.Services.AddAutoMapper(cfg => +{ + cfg.AddProfile(); +}); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); //this would need adjusted if we were doing migrations, i dont have a database existing yet so for simplicity im just ensuring created here for it to create and seed the database +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentDetailView.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentDetailView.cs new file mode 100644 index 00000000..706c581e --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentDetailView.cs @@ -0,0 +1,18 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace InvestmentPerformanceWebApi.Api.ViewModels.Investments +{ + public class InvestmentDetailView + { + public string Name { get; set; } = string.Empty; + public int Id { get; set; } + public int Shares { get; set; } + public decimal CostBasis { get; set; } + public decimal CurrentPrice { get; set; } + public decimal CurrentValue { get; set; } + public decimal TotalGainOrLoss { get; set; } + public string Term { get; set; } = string.Empty; + } + +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentSummaryView.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentSummaryView.cs new file mode 100644 index 00000000..6af4c26b --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/ViewModels/Investments/InvestmentSummaryView.cs @@ -0,0 +1,8 @@ +namespace InvestmentPerformanceWebApi.Api.ViewModels.Investments +{ + public class InvestmentSummaryView + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.Development.json b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.json b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/AppDbContext.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/AppDbContext.cs new file mode 100644 index 00000000..8f8daecf --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/AppDbContext.cs @@ -0,0 +1,91 @@ +using InvestmentPerformanceWebApi.DbInfrastructure.Models; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformanceWebApi.DbInfrastructure +{ + public class AppDbContext : DbContext + { + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users => Set(); + public DbSet Investments => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + + modelBuilder.Entity() + .HasKey(t => t.Id); + + + modelBuilder.Entity(investment => + { + investment.HasKey(t => t.Id); + + investment.Property(i => i.Id) + .ValueGeneratedNever(); + + investment.HasOne(t => t.User) + .WithMany(u => u.Investments) + .HasForeignKey(u => u.UserId) + .IsRequired(); + + investment.HasIndex(i => i.UserId); + + }); + + var user1 = Guid.NewGuid(); + + modelBuilder.Entity().HasData( + new User + { + Id = 1, + UserName = "becho", + FirstName = "Echo", + LastName = "Bazaz", + CreatedUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow + } + ); + + modelBuilder.Entity().HasData( + new Investment + { + Id = 1, + UserId = 1, + Name = "Stock A", + Shares = 20, + CostBasis = 11.75m, + CurrentPrice = 80.90m, + PurchasedAtUtc = DateTime.UtcNow.AddMonths(-6), + CreatedAtUtc = DateTime.UtcNow + }, + new Investment + { + Id = 2, + UserId = 1, + Name = "Stock B", + Shares = 8, + CostBasis = 50.00m, + CurrentPrice = 45.69m, + PurchasedAtUtc = DateTime.UtcNow.AddDays(-3), + CreatedAtUtc = DateTime.UtcNow + }, + new Investment + { + Id = 3, + UserId = 1, + Name = "Stock C", + Shares = 2, + CostBasis = 50.00m, + CurrentPrice = 250.00m, + PurchasedAtUtc = DateTime.UtcNow.AddYears(-10), + CreatedAtUtc = DateTime.UtcNow + } + ); + } + + + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/Investment.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/Investment.cs new file mode 100644 index 00000000..1b76fd03 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/Investment.cs @@ -0,0 +1,17 @@ + +namespace InvestmentPerformanceWebApi.DbInfrastructure.Models +{ + public class Investment + { + public int Id { get; set; } + public int UserId { get; set; } + public User User { get; set; } = null!; + public string Name { get; set; } = null!; + public int Shares { get; set; } + public decimal CostBasis { get; set; } //price of 1 share of stock at time it was purchased + public decimal CurrentPrice { get; set; } // current price of 1 share of stock + public DateTime PurchasedAtUtc { get; set; } // can be used to calculate the Term + public DateTime CreatedAtUtc { get; set; } + + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/User.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/User.cs new file mode 100644 index 00000000..d3655857 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/DbInfrastructure/Models/User.cs @@ -0,0 +1,15 @@ + +namespace InvestmentPerformanceWebApi.DbInfrastructure.Models +{ + public class User + { + public int Id { get; set; } + public string UserName { get; set; } = null!; + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public DateTime CreatedUtc { get; set; } + public DateTime LastUpdatedUtc { get; set; } + public ICollection Investments { get; set; } = null!; + + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Interfaces/IInvestmentService.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Interfaces/IInvestmentService.cs new file mode 100644 index 00000000..f6732a33 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Interfaces/IInvestmentService.cs @@ -0,0 +1,10 @@ +using InvestmentPerformanceWebApi.Domain.Models; + +namespace InvestmentPerformanceWebApi.Services.Interfaces +{ + public interface IInvestmentService + { + Task> GetInvestmentsForUserAsync(int userId); + Task GetInvestmentForUserAsync(int userId, int investmentId); + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Models/Investment.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Models/Investment.cs new file mode 100644 index 00000000..f4f0ffba --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Models/Investment.cs @@ -0,0 +1,47 @@ + + +namespace InvestmentPerformanceWebApi.Domain.Models +{ + public class Investment + { + public string Name { get; private set; } + public int Id { get; private set; } + public int Shares { get; private set; } + public decimal CostBasis { get; private set; } + public decimal CurrentPrice { get; private set; } + public DateTime PurchasedUtc { get; private set; } + + public decimal CurrentValue => Shares * CurrentPrice; + + public string Term => GetTerm(DateTime.UtcNow); + public decimal TotalGainOrLoss => Shares * CurrentPrice - Shares * CostBasis; + + + private string GetTerm(DateTime asOf) + { + return (asOf - PurchasedUtc).TotalDays >= 365 + ? "Long Term" + : "Short Term"; + } + + public Investment( + string name, + int id, + int shares, + decimal costBasis, + decimal currentPrice, + DateTime purchasedDateUtc) + { + if (shares < 0) + { + throw new ArgumentException("Shares must be greater than 0"); + } + Name = name; + Id = id; + Shares = shares; + CostBasis = costBasis; + CurrentPrice = currentPrice; + PurchasedUtc = purchasedDateUtc; + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Services/InvestmentService.cs b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Services/InvestmentService.cs new file mode 100644 index 00000000..847762b4 --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Domain/Services/InvestmentService.cs @@ -0,0 +1,50 @@ +using InvestmentPerformanceWebApi.DbInfrastructure; +using InvestmentPerformanceWebApi.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace InvestmentPerformanceWebApi.Domain.Services +{ + public class InvestmentService : IInvestmentService + { + private readonly AppDbContext _context; + + public InvestmentService(AppDbContext context) + { + _context = context; + } + + public async Task> GetInvestmentsForUserAsync(int userId) + { + var userExists = await _context.Users.AnyAsync(u => u.Id == userId); + if(!userExists) + { + throw new KeyNotFoundException($"User with Id {userId} not found"); + } + + var investments = await _context.Investments + .Where(i => i.UserId == userId) + .Select(x => new Models.Investment(x.Name, x.Id, x.Shares, x.CostBasis, x.CurrentPrice, x.PurchasedAtUtc)) + .ToListAsync(); + + return investments; + + + } + + public async Task GetInvestmentForUserAsync(int userId, int investmentId) + { + + var investment = await _context.Investments + .Where(i => i.UserId == userId && i.Id == investmentId) + .Select(x => new Models.Investment(x.Name, x.Id, x.Shares, x.CostBasis, x.CurrentPrice, x.PurchasedAtUtc)) + .FirstOrDefaultAsync(); + + if (investment is null) + { + throw new KeyNotFoundException($"Either Investment {investmentId} or user {userId} invalid"); + } + + return investment; + } + } +} diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj new file mode 100644 index 00000000..4b6b9b2a --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj.user b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj.user new file mode 100644 index 00000000..9ff5820a --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Properties/launchSettings.json b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Properties/launchSettings.json new file mode 100644 index 00000000..d6581c4a --- /dev/null +++ b/InvestmentPerformanceWebApi/InvestmentPerformanceWebApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14211", + "sslPort": 44348 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5019", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7130;http://localhost:5019", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InvestmentPerformanceWebApi/README.md b/InvestmentPerformanceWebApi/README.md new file mode 100644 index 00000000..ab6d162f --- /dev/null +++ b/InvestmentPerformanceWebApi/README.md @@ -0,0 +1,55 @@ +# Investment System - README + +## Overview +**Current Required Functionalities:** +1. **Get investments for a user** + - Returns investment ID and investment name. + - **Note:** This returns the **investment name**, not the user's name. + +2. **Get investment by ID** + - Returns details including: + - Shares + - Cost per share + - Current value + - Current price + - Term + - Total gain/loss + +--- + +## Assumptions +- The database and tables do **not exist** yet; no predefined entity models were provided. +- This system is **read-only** at this stage (no inserts/updates). +- The system is assumed to be an **online portal**, and users can only access **their own investments**. +- Investments cannot exist without a user, and an investment belongs to **only one user**. +- Current price and value of investments are updated dynamically (e.g., via a separate process or live API). +- All investments are assumed to be in a **single currency (USD)**. +- The "name" returned by the API refers to the **investment name**, not the user name. + +**Additional Notes on Design:** +- Considered a 3-layer architecture (View, Domain Model with logic, DB Entity). + - Views(DTOs) are kept separate from the domain model for flexibility. + - If calculated fields were stored in the database, design decisions may change. + +--- + +## Running the Application +- Uses **SQLite** as the database. +- Auto-seeding of data is configured in `Program.cs`. No migrations are needed. +- **Endpoints:** + - `GET /users/{userId}/investments/` → returns all investments for a user + - `GET /users/{userId}/investments/{investmentId}` → returns investment details by ID +- Check the startup window to see which **port** the API is running on locally. +- **Seed Data:** + - Defined in `AppDbContext.cs` + - Includes User 1 and Investments 1, 2, and 3 + +--- + +## Future Considerations +- Support for multiple currencies +- Integration with live stock price APIs +- Extended features for admin users or multi-user investment views +- Buy/sell, deposit, withdrawal, and reporting functionalities +- validations when data inserted into database, determine validation rules +- Is there need to calculate gain/loss and total value outside of investment domain model?