From af4bec0e18dde08745d280d55bc0c849df8bdeb3 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Wed, 24 Dec 2025 22:13:42 -0600 Subject: [PATCH 1/9] See REVISIONS.md for details --- .vscode/launch.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..574ca21 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C#: Launch Startup Project", + "type": "dotnet", + "request": "launch" + } + ] +} \ No newline at end of file From 6f12dc8236593904ff2458b0f555ef4c4b78ee96 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Fri, 26 Dec 2025 16:12:11 -0600 Subject: [PATCH 2/9] See REVISIONS.md for details --- Aquiis.SimpleStart.Tests/BaseServiceTests.cs | 692 ++++++++++++++++++ .../PropertyServiceTests.cs | 666 +++++++++++++++++ .../Application/Services/PropertyService.cs | 332 +++++++++ Aquiis.SimpleStart/Core/Entities/BaseModel.cs | 5 +- .../Core/Interfaces/IAuditable.cs | 32 + .../Core/Services/BaseService.cs | 369 ++++++++++ Aquiis.SimpleStart/Program.cs | 1 + 7 files changed, 2095 insertions(+), 2 deletions(-) create mode 100644 Aquiis.SimpleStart.Tests/BaseServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/PropertyServiceTests.cs create mode 100644 Aquiis.SimpleStart/Application/Services/PropertyService.cs create mode 100644 Aquiis.SimpleStart/Core/Interfaces/IAuditable.cs create mode 100644 Aquiis.SimpleStart/Core/Services/BaseService.cs diff --git a/Aquiis.SimpleStart.Tests/BaseServiceTests.cs b/Aquiis.SimpleStart.Tests/BaseServiceTests.cs new file mode 100644 index 0000000..23342a1 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/BaseServiceTests.cs @@ -0,0 +1,692 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Unit tests for BaseService generic CRUD operations. + /// Tests organization isolation, soft delete, audit fields, and security. + /// + public class BaseServiceTests : IDisposable + { + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly TestPropertyService _service; + private readonly string _testUserId; + private readonly Guid _testOrgId; + private readonly Microsoft.Data.Sqlite.SqliteConnection _connection; + + public BaseServiceTests() + { + // Setup SQLite in-memory database + _connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Setup test user and organization + _testUserId = "test-user-123"; + _testOrgId = Guid.NewGuid(); + + // Mock AuthenticationStateProvider + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "test@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Seed test data + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "test@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + var org = new Organization + { + Id = _testOrgId, + Name = "Test Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(org); + _context.SaveChanges(); + + // Create service with mocked settings + var mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + var mockLogger = new Mock>(); + _service = new TestPropertyService(_context, mockLogger.Object, _userContext, mockSettings); + } + + public void Dispose() + { + _context?.Dispose(); + _connection?.Dispose(); + } + + #region CreateAsync Tests + + [Fact] + public async Task CreateAsync_ValidEntity_CreatesSuccessfully() + { + // Arrange + var property = new Property + { + Address = "123 Main St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House" + }; + + // Act + var result = await _service.CreateAsync(property); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal(_testOrgId, result.OrganizationId); + Assert.Equal(_testUserId, result.CreatedBy); + Assert.True(result.CreatedOn <= DateTime.UtcNow); + Assert.False(result.IsDeleted); + } + + [Fact] + public async Task CreateAsync_AutoGeneratesIdIfEmpty() + { + // Arrange + var property = new Property + { + Id = Guid.Empty, // Explicitly empty + Address = "456 Oak Ave", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Apartment" + }; + + // Act + var result = await _service.CreateAsync(property); + + // Assert + Assert.NotEqual(Guid.Empty, result.Id); + } + + [Fact] + public async Task CreateAsync_SetsAuditFieldsAutomatically() + { + // Arrange + var property = new Property + { + Address = "789 Pine St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Condo" + }; + var beforeCreate = DateTime.UtcNow; + + // Act + var result = await _service.CreateAsync(property); + + // Assert + Assert.Equal(_testUserId, result.CreatedBy); + Assert.True(result.CreatedOn >= beforeCreate); + Assert.True(result.CreatedOn <= DateTime.UtcNow); + Assert.Null(result.LastModifiedBy); + Assert.Null(result.LastModifiedOn); + } + + [Fact] + public async Task CreateAsync_SetsOrganizationIdAutomatically() + { + // Arrange + var property = new Property + { + OrganizationId = Guid.Empty, // Even if explicitly empty + Address = "321 Elm St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Townhouse" + }; + + // Act + var result = await _service.CreateAsync(property); + + // Assert + Assert.Equal(_testOrgId, result.OrganizationId); + } + + #endregion + + #region GetByIdAsync Tests + + [Fact] + public async Task GetByIdAsync_ExistingEntity_ReturnsEntity() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "555 Maple Dr", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(property.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(property.Id, result.Id); + Assert.Equal(property.Address, result.Address); + } + + [Fact] + public async Task GetByIdAsync_NonExistentEntity_ReturnsNull() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var result = await _service.GetByIdAsync(nonExistentId); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetByIdAsync_SoftDeletedEntity_ReturnsNull() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "777 Birch Ln", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow, + IsDeleted = true // Soft deleted + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(property.Id); + + // Assert + Assert.Null(result); // Should not return deleted entities + } + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange + var differentOrgId = Guid.NewGuid(); + var differentOrg = new Organization + { + Id = differentOrgId, + Name = "Different Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(differentOrg); + + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = differentOrgId, // Different organization + Address = "999 Cedar Ct", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(property.Id); + + // Assert + Assert.Null(result); // Should not return entities from other orgs + } + + #endregion + + #region GetAllAsync Tests + + [Fact] + public async Task GetAllAsync_ReturnsAllActiveEntities() + { + // Arrange + var properties = new[] + { + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "300 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow } + }; + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Equal(3, result.Count); + } + + [Fact] + public async Task GetAllAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var activeProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "400 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow, IsDeleted = false }; + var deletedProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "500 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow, IsDeleted = true }; + _context.Properties.AddRange(activeProperty, deletedProperty); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(activeProperty.Id, result[0].Id); + } + + [Fact] + public async Task GetAllAsync_FiltersOnlyCurrentOrganization() + { + // Arrange + var differentOrgId = Guid.NewGuid(); + var differentOrg = new Organization + { + Id = differentOrgId, + Name = "Different Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(differentOrg); + + var myProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "600 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; + var otherProperty = new Property { Id = Guid.NewGuid(), OrganizationId = differentOrgId, Address = "700 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; + _context.Properties.AddRange(myProperty, otherProperty); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(myProperty.Id, result[0].Id); + } + + #endregion + + #region UpdateAsync Tests + + [Fact] + public async Task UpdateAsync_ValidEntity_UpdatesSuccessfully() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "800 Original St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + property.Address = "800 Updated St"; + var beforeUpdate = DateTime.UtcNow; + + // Act + var result = await _service.UpdateAsync(property); + + // Assert + Assert.Equal("800 Updated St", result.Address); + Assert.Equal(_testUserId, result.LastModifiedBy); + Assert.NotNull(result.LastModifiedOn); + Assert.True(result.LastModifiedOn >= beforeUpdate); + } + + [Fact] + public async Task UpdateAsync_SetsLastModifiedFields() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "900 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + property.MonthlyRent = 1500m; + var beforeUpdate = DateTime.UtcNow; + + // Act + var result = await _service.UpdateAsync(property); + + // Assert + Assert.Equal(_testUserId, result.LastModifiedBy); + Assert.NotNull(result.LastModifiedOn); + Assert.True(result.LastModifiedOn >= beforeUpdate); + Assert.True(result.LastModifiedOn <= DateTime.UtcNow); + } + + [Fact] + public async Task UpdateAsync_NonExistentEntity_ThrowsException() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), // Not in database + OrganizationId = _testOrgId, + Address = "1000 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House" + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.UpdateAsync(property)); + } + + [Fact] + public async Task UpdateAsync_DifferentOrganization_ThrowsUnauthorizedException() + { + // Arrange + var differentOrgId = Guid.NewGuid(); + var differentOrg = new Organization + { + Id = differentOrgId, + Name = "Different Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(differentOrg); + + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = differentOrgId, + Address = "1100 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + property.Address = "1100 Updated St"; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.UpdateAsync(property)); + } + + [Fact] + public async Task UpdateAsync_PreventsOrganizationHijacking() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "1200 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Detach the entity so we can simulate an external update attempt + _context.Entry(property).State = Microsoft.EntityFrameworkCore.EntityState.Detached; + + // Attempt to change organization on a new instance + var updatedProperty = new Property + { + Id = property.Id, + OrganizationId = Guid.NewGuid(), // Try to hijack + Address = "1200 Updated St", // Also update something else + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = property.CreatedOn + }; + + // Act + var result = await _service.UpdateAsync(updatedProperty); + + // Assert - OrganizationId should be preserved as original + Assert.Equal(_testOrgId, result.OrganizationId); + Assert.Equal("1200 Updated St", result.Address); // Other changes should apply + } + + #endregion + + #region DeleteAsync Tests + + [Fact] + public async Task DeleteAsync_SoftDeleteEnabled_SoftDeletesEntity() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "1300 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.DeleteAsync(property.Id); + + // Assert + Assert.True(result); + var deletedEntity = await _context.Properties.FindAsync(property.Id); + Assert.NotNull(deletedEntity); + Assert.True(deletedEntity!.IsDeleted); + Assert.Equal(_testUserId, deletedEntity.LastModifiedBy); + Assert.NotNull(deletedEntity.LastModifiedOn); + } + + [Fact] + public async Task DeleteAsync_NonExistentEntity_ReturnsFalse() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var result = await _service.DeleteAsync(nonExistentId); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteAsync_DifferentOrganization_ThrowsUnauthorizedException() + { + // Arrange + var differentOrgId = Guid.NewGuid(); + var differentOrg = new Organization + { + Id = differentOrgId, + Name = "Different Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(differentOrg); + + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = differentOrgId, + Address = "1400 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + await _context.SaveChangesAsync(); + + // Act & Assert + await Assert.ThrowsAsync(() => _service.DeleteAsync(property.Id)); + } + + #endregion + + #region Security & Authorization Tests + + [Fact] + public async Task CreateAsync_UnauthenticatedUser_ThrowsUnauthorizedException() + { + // Arrange - Create service with no authenticated user + var mockAuth = new Mock(); + var claims = new ClaimsPrincipal(new ClaimsIdentity()); // Not authenticated + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + + var serviceProvider = new Mock(); + var unauthorizedUserContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + var mockSettings = Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }); + var mockLogger = new Mock>(); + var unauthorizedService = new TestPropertyService(_context, mockLogger.Object, unauthorizedUserContext, mockSettings); + + var property = new Property + { + Address = "1500 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House" + }; + + // Act & Assert + await Assert.ThrowsAsync(() => unauthorizedService.CreateAsync(property)); + } + + #endregion + + /// + /// Test implementation of BaseService using Property entity for testing purposes. + /// + public class TestPropertyService : BaseService + { + public TestPropertyService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + } + } +} diff --git a/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs b/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs new file mode 100644 index 0000000..80a9ac9 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs @@ -0,0 +1,666 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Application.Services; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Unit tests for PropertyService business logic and property-specific operations. + /// + public class PropertyServiceTests : IDisposable + { + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly PropertyService _service; + private readonly string _testUserId; + private readonly Guid _testOrgId; + private readonly Microsoft.Data.Sqlite.SqliteConnection _connection; + + public PropertyServiceTests() + { + // Setup SQLite in-memory database + _connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Setup test user and organization + _testUserId = "test-user-123"; + _testOrgId = Guid.NewGuid(); + + // Mock AuthenticationStateProvider + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "test@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Seed test data + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "test@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + var org = new Organization + { + Id = _testOrgId, + Name = "Test Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(org); + _context.SaveChanges(); + + // Create PropertyService with mocked dependencies + var mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + var mockLogger = new Mock>(); + + // Create real CalendarEventService for testing + var mockCalendarSettings = new Mock(_context, _userContext); + var calendarService = new CalendarEventService(_context, mockCalendarSettings.Object, _userContext); + + _service = new PropertyService(_context, mockLogger.Object, _userContext, mockSettings, calendarService); + } + + public void Dispose() + { + _context?.Dispose(); + _connection?.Dispose(); + } + + #region CreateAsync Override Tests + + [Fact] + public async Task CreateAsync_SetsNextRoutineInspectionDate() + { + // Arrange + var property = new Property + { + Address = "123 Main St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House" + }; + var expectedDate = DateTime.Today.AddDays(30); + + // Act + var result = await _service.CreateAsync(property); + + // Assert + Assert.NotNull(result.NextRoutineInspectionDueDate); + Assert.Equal(expectedDate, result.NextRoutineInspectionDueDate!.Value.Date); + } + + #endregion + + #region Validation Tests + + [Fact] + public async Task CreateAsync_MissingAddress_ThrowsValidationException() + { + // Arrange + var property = new Property + { + Address = "", // Empty address + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House" + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(property)); + } + + [Fact] + public async Task CreateAsync_DuplicateAddress_ThrowsValidationException() + { + // Arrange + var existingProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "456 Duplicate St", + City = "Same City", + State = "SC", + ZipCode = "54321", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(existingProperty); + await _context.SaveChangesAsync(); + + var duplicateProperty = new Property + { + Address = "456 Duplicate St", // Same address + City = "Same City", + State = "SC", + ZipCode = "54321", + PropertyType = "Apartment" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.CreateAsync(duplicateProperty)); + Assert.Contains("already exists", exception.Message); + } + + [Fact] + public async Task CreateAsync_SameAddressDifferentOrganization_AllowsCreation() + { + // Arrange + var differentOrgId = Guid.NewGuid(); + var differentOrg = new Organization + { + Id = differentOrgId, + Name = "Different Org", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(differentOrg); + + var existingProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = differentOrgId, // Different organization + Address = "789 Shared St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(existingProperty); + await _context.SaveChangesAsync(); + + var newProperty = new Property + { + Address = "789 Shared St", // Same address, different org + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Apartment" + }; + + // Act + var result = await _service.CreateAsync(newProperty); + + // Assert + Assert.NotNull(result); + Assert.Equal(newProperty.Address, result.Address); + } + + #endregion + + #region GetPropertyWithRelationsAsync Tests + + [Fact] + public async Task GetPropertyWithRelationsAsync_LoadsLeasesAndDocuments() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "100 Relation St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + + var tenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + PhoneNumber = "555-1234", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + var lease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + TenantId = tenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500m, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(lease); + + var document = new Document + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + FileName = "test.pdf", + FileType = "application/pdf", + FileSize = 1024, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Documents.Add(document); + + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetPropertyWithRelationsAsync(property.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Leases); + Assert.NotNull(result.Documents); + Assert.Single(result.Leases); + Assert.Single(result.Documents); + } + + #endregion + + #region SearchPropertiesByAddressAsync Tests + + [Fact] + public async Task SearchPropertiesByAddressAsync_EmptySearchTerm_ReturnsFirst20() + { + // Arrange - Create 25 properties + for (int i = 1; i <= 25; i++) + { + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = $"{i * 100} Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + } + await _context.SaveChangesAsync(); + + // Act + var result = await _service.SearchPropertiesByAddressAsync(""); + + // Assert + Assert.Equal(20, result.Count); // Should limit to 20 + } + + [Fact] + public async Task SearchPropertiesByAddressAsync_SearchByAddress_ReturnsMatches() + { + // Arrange + var properties = new[] + { + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "123 Main St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "456 Main Ave", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "789 Oak St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow } + }; + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.SearchPropertiesByAddressAsync("Main"); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, p => Assert.Contains("Main", p.Address)); + } + + [Fact] + public async Task SearchPropertiesByAddressAsync_SearchByCity_ReturnsMatches() + { + // Arrange + var properties = new[] + { + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "Springfield", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "Springfield", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "300 Test St", City = "Shelbyville", State = "ST", ZipCode = "54321", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow } + }; + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.SearchPropertiesByAddressAsync("Springfield"); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, p => Assert.Equal("Springfield", p.City)); + } + + [Fact] + public async Task SearchPropertiesByAddressAsync_SearchByZipCode_ReturnsMatches() + { + // Arrange + var properties = new[] + { + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "City", State = "ST", ZipCode = "90210", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow } + }; + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.SearchPropertiesByAddressAsync("90210"); + + // Assert + Assert.Single(result); + Assert.Equal("90210", result[0].ZipCode); + } + + #endregion + + #region GetVacantPropertiesAsync Tests + + [Fact] + public async Task GetVacantPropertiesAsync_ReturnsOnlyVacantProperties() + { + // Arrange + var vacantProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "100 Vacant St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + var occupiedProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "200 Occupied St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + _context.Properties.AddRange(vacantProperty, occupiedProperty); + await _context.SaveChangesAsync(); + + // Add active lease to occupied property + var tenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FirstName = "John", + LastName = "Doe", + Email = "john@example.com", + PhoneNumber = "555-1234", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + var activeLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = occupiedProperty.Id, + TenantId = tenant.Id, + StartDate = DateTime.Today.AddMonths(-1), + EndDate = DateTime.Today.AddMonths(11), + MonthlyRent = 1500m, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(activeLease); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetVacantPropertiesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(vacantProperty.Id, result[0].Id); + } + + [Fact] + public async Task GetVacantPropertiesAsync_ExcludesUnavailableProperties() + { + // Arrange + var unavailableProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "300 Unavailable St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + IsAvailable = false, // Not available + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(unavailableProperty); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetVacantPropertiesAsync(); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region CalculateOccupancyRateAsync Tests + + [Fact] + public async Task CalculateOccupancyRateAsync_NoProperties_ReturnsZero() + { + // Act + var result = await _service.CalculateOccupancyRateAsync(); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public async Task CalculateOccupancyRateAsync_CalculatesCorrectPercentage() + { + // Arrange - Create 4 available properties, 3 occupied + var properties = Enumerable.Range(1, 4).Select(i => new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = $"{i}00 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }).ToArray(); + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Create tenant + var tenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FirstName = "Jane", + LastName = "Smith", + Email = "jane@example.com", + PhoneNumber = "555-5678", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + // Add active leases to 3 properties + for (int i = 0; i < 3; i++) + { + var lease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = properties[i].Id, + TenantId = tenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500m, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(lease); + } + await _context.SaveChangesAsync(); + + // Act + var result = await _service.CalculateOccupancyRateAsync(); + + // Assert + Assert.Equal(75m, result); // 3 out of 4 = 75% + } + + #endregion + + #region GetPropertiesDueForInspectionAsync Tests + + [Fact] + public async Task GetPropertiesDueForInspectionAsync_ReturnsDueProperties() + { + // Arrange + var dueProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "400 Due St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + NextRoutineInspectionDueDate = DateTime.Today.AddDays(5), // Due in 5 days + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + var notDueProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "500 Not Due St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "House", + NextRoutineInspectionDueDate = DateTime.Today.AddDays(30), // Not due yet + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + _context.Properties.AddRange(dueProperty, notDueProperty); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetPropertiesDueForInspectionAsync(7); + + // Assert + Assert.Single(result); + Assert.Equal(dueProperty.Id, result[0].Id); + } + + [Fact] + public async Task GetPropertiesDueForInspectionAsync_OrdersByDueDate() + { + // Arrange + var properties = new[] + { + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "600 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(5), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "700 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(2), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }, + new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "800 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(7), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow } + }; + _context.Properties.AddRange(properties); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetPropertiesDueForInspectionAsync(10); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("700 Test St", result[0].Address); // Due in 2 days (first) + Assert.Equal("600 Test St", result[1].Address); // Due in 5 days (second) + Assert.Equal("800 Test St", result[2].Address); // Due in 7 days (third) + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/PropertyService.cs b/Aquiis.SimpleStart/Application/Services/PropertyService.cs new file mode 100644 index 0000000..51f8622 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/PropertyService.cs @@ -0,0 +1,332 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Property entities. + /// Inherits common CRUD operations from BaseService and adds property-specific business logic. + /// + public class PropertyService : BaseService + { + private readonly CalendarEventService _calendarEventService; + private readonly ApplicationSettings _appSettings; + + public PropertyService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings, + CalendarEventService calendarEventService) + : base(context, logger, userContext, settings) + { + _calendarEventService = calendarEventService; + _appSettings = settings.Value; + } + + #region Overrides with Property-Specific Logic + + /// + /// Creates a new property with initial routine inspection scheduling. + /// + public override async Task CreateAsync(Property property) + { + // Set initial routine inspection due date to 30 days from creation + property.NextRoutineInspectionDueDate = DateTime.Today.AddDays(30); + + // Call base create (handles audit fields, org assignment, validation) + var createdProperty = await base.CreateAsync(property); + + // Create calendar event for the first routine inspection + await CreateRoutineInspectionCalendarEventAsync(createdProperty); + + return createdProperty; + } + + /// + /// Retrieves a property by ID with related entities (Leases, Documents). + /// + public async Task GetPropertyWithRelationsAsync(Guid propertyId) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Properties + .Include(p => p.Leases) + .Include(p => p.Documents) + .FirstOrDefaultAsync(p => p.Id == propertyId && + p.OrganizationId == organizationId && + !p.IsDeleted); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPropertyWithRelations"); + throw; + } + } + + /// + /// Retrieves all properties with related entities. + /// + public async Task> GetPropertiesWithRelationsAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Properties + .Include(p => p.Leases) + .Include(p => p.Documents) + .Where(p => !p.IsDeleted && p.OrganizationId == organizationId) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPropertiesWithRelations"); + throw; + } + } + + /// + /// Validates property data before create/update operations. + /// + protected override async Task ValidateEntityAsync(Property property) + { + // Validate required address + if (string.IsNullOrWhiteSpace(property.Address)) + { + throw new ValidationException("Property address is required."); + } + + // Check for duplicate address in same organization + var userId = await _userContext.GetUserIdAsync(); + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var exists = await _context.Properties + .AnyAsync(p => p.Address == property.Address && + p.City == property.City && + p.State == property.State && + p.ZipCode == property.ZipCode && + p.Id != property.Id && + p.OrganizationId == organizationId && + !p.IsDeleted); + + if (exists) + { + throw new ValidationException($"A property with address '{property.Address}' already exists in this location."); + } + + await base.ValidateEntityAsync(property); + } + + #endregion + + #region Business Logic Methods + + /// + /// Searches properties by address, city, state, or zip code. + /// + public async Task> SearchPropertiesByAddressAsync(string searchTerm) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return await _context.Properties + .Where(p => !p.IsDeleted && p.OrganizationId == organizationId) + .OrderBy(p => p.Address) + .Take(20) + .ToListAsync(); + } + + return await _context.Properties + .Where(p => !p.IsDeleted && + p.OrganizationId == organizationId && + (p.Address.Contains(searchTerm) || + p.City.Contains(searchTerm) || + p.State.Contains(searchTerm) || + p.ZipCode.Contains(searchTerm))) + .OrderBy(p => p.Address) + .Take(20) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "SearchPropertiesByAddress"); + throw; + } + } + + /// + /// Retrieves all vacant properties (no active leases). + /// + public async Task> GetVacantPropertiesAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Properties + .Where(p => !p.IsDeleted && + p.IsAvailable && + p.OrganizationId == organizationId) + .Where(p => !_context.Leases.Any(l => + l.PropertyId == p.Id && + l.Status == Core.Constants.ApplicationConstants.LeaseStatuses.Active && + !l.IsDeleted)) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetVacantProperties"); + throw; + } + } + + /// + /// Calculates the overall occupancy rate for the organization. + /// + public async Task CalculateOccupancyRateAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var totalProperties = await _context.Properties + .CountAsync(p => !p.IsDeleted && p.IsAvailable && p.OrganizationId == organizationId); + + if (totalProperties == 0) + { + return 0; + } + + var occupiedProperties = await _context.Properties + .CountAsync(p => !p.IsDeleted && + p.IsAvailable && + p.OrganizationId == organizationId && + _context.Leases.Any(l => + l.PropertyId == p.Id && + l.Status == Core.Constants.ApplicationConstants.LeaseStatuses.Active && + !l.IsDeleted)); + + return (decimal)occupiedProperties / totalProperties * 100; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateOccupancyRate"); + throw; + } + } + + /// + /// Retrieves properties that need routine inspection. + /// + public async Task> GetPropertiesDueForInspectionAsync(int daysAhead = 7) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var cutoffDate = DateTime.Today.AddDays(daysAhead); + + return await _context.Properties + .Where(p => !p.IsDeleted && + p.OrganizationId == organizationId && + p.NextRoutineInspectionDueDate.HasValue && + p.NextRoutineInspectionDueDate.Value <= cutoffDate) + .OrderBy(p => p.NextRoutineInspectionDueDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPropertiesDueForInspection"); + throw; + } + } + + #endregion + + #region Helper Methods + + /// + /// Creates a calendar event for routine property inspection. + /// + private async Task CreateRoutineInspectionCalendarEventAsync(Property property) + { + if (!property.NextRoutineInspectionDueDate.HasValue) + { + return; + } + + var userId = await _userContext.GetUserIdAsync(); + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var calendarEvent = new CalendarEvent + { + Id = Guid.NewGuid(), + Title = $"Routine Inspection - {property.Address}", + Description = $"Scheduled routine inspection for property at {property.Address}", + StartOn = property.NextRoutineInspectionDueDate.Value, + EndOn = property.NextRoutineInspectionDueDate.Value.AddHours(1), + DurationMinutes = 60, + Location = property.Address, + SourceEntityType = nameof(Property), + SourceEntityId = property.Id, + PropertyId = property.Id, + OrganizationId = organizationId!.Value, + CreatedBy = userId!, + CreatedOn = DateTime.UtcNow, + EventType = "Inspection", + Status = "Scheduled" + }; + + await _calendarEventService.CreateCustomEventAsync(calendarEvent); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Core/Entities/BaseModel.cs b/Aquiis.SimpleStart/Core/Entities/BaseModel.cs index 89ffb35..afd11a5 100644 --- a/Aquiis.SimpleStart/Core/Entities/BaseModel.cs +++ b/Aquiis.SimpleStart/Core/Entities/BaseModel.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using Aquiis.SimpleStart.Core.Interfaces; namespace Aquiis.SimpleStart.Core.Entities { - public class BaseModel + public class BaseModel : IAuditable { [Key] [JsonInclude] @@ -33,7 +34,7 @@ public class BaseModel [StringLength(100)] [DataType(DataType.Text)] [Display(Name = "Last Modified By")] - public string? LastModifiedBy { get; set; } = string.Empty; + public string? LastModifiedBy { get; set; } [JsonInclude] [Display(Name = "Is Deleted?")] diff --git a/Aquiis.SimpleStart/Core/Interfaces/IAuditable.cs b/Aquiis.SimpleStart/Core/Interfaces/IAuditable.cs new file mode 100644 index 0000000..3d93b5a --- /dev/null +++ b/Aquiis.SimpleStart/Core/Interfaces/IAuditable.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Core.Interfaces +{ + /// + /// Interface for entities that track audit information (creation and modification). + /// Entities implementing this interface will have their audit fields automatically + /// managed by the BaseService during create and update operations. + /// + public interface IAuditable + { + /// + /// Date and time when the entity was created (UTC). + /// + DateTime CreatedOn { get; set; } + + /// + /// User ID of the user who created the entity. + /// + string CreatedBy { get; set; } + + /// + /// Date and time when the entity was last modified (UTC). + /// + DateTime? LastModifiedOn { get; set; } + + /// + /// User ID of the user who last modified the entity. + /// + string? LastModifiedBy { get; set; } + } +} diff --git a/Aquiis.SimpleStart/Core/Services/BaseService.cs b/Aquiis.SimpleStart/Core/Services/BaseService.cs new file mode 100644 index 0000000..ac7c5c9 --- /dev/null +++ b/Aquiis.SimpleStart/Core/Services/BaseService.cs @@ -0,0 +1,369 @@ +using System.Linq.Expressions; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Core.Services +{ + /// + /// Abstract base service providing common CRUD operations for entities. + /// Implements organization-based multi-tenancy, soft delete support, + /// and automatic audit field management. + /// + /// Entity type that inherits from BaseModel + public abstract class BaseService where TEntity : BaseModel + { + protected readonly ApplicationDbContext _context; + protected readonly ILogger> _logger; + protected readonly UserContextService _userContext; + protected readonly ApplicationSettings _settings; + protected readonly DbSet _dbSet; + + protected BaseService( + ApplicationDbContext context, + ILogger> logger, + UserContextService userContext, + IOptions settings) + { + _context = context; + _logger = logger; + _userContext = userContext; + _settings = settings.Value; + _dbSet = context.Set(); + } + + #region CRUD Operations + + /// + /// Retrieves an entity by its ID with organization isolation. + /// Returns null if entity not found or belongs to different organization. + /// Automatically filters out soft-deleted entities. + /// + public virtual async Task GetByIdAsync(Guid id) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var entity = await _dbSet + .FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted); + + if (entity == null) + { + _logger.LogWarning($"{typeof(TEntity).Name} not found: {id}"); + return null; + } + + // Verify organization access if entity has OrganizationId property + if (HasOrganizationIdProperty(entity)) + { + var entityOrgId = GetOrganizationId(entity); + if (entityOrgId != organizationId) + { + _logger.LogWarning($"Unauthorized access to {typeof(TEntity).Name} {id} from organization {organizationId}"); + return null; + } + } + + return entity; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, $"GetById{typeof(TEntity).Name}"); + throw; + } + } + + /// + /// Retrieves all entities for the current organization. + /// Automatically filters out soft-deleted entities and applies organization isolation. + /// + public virtual async Task> GetAllAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + IQueryable query = _dbSet.Where(e => !e.IsDeleted); + + // Apply organization filter if entity has OrganizationId property + if (typeof(TEntity).GetProperty("OrganizationId") != null) + { + var parameter = Expression.Parameter(typeof(TEntity), "e"); + var property = Expression.Property(parameter, "OrganizationId"); + var constant = Expression.Constant(organizationId); + var condition = Expression.Equal(property, constant); + var lambda = Expression.Lambda>(condition, parameter); + + query = query.Where(lambda); + } + + return await query.ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, $"GetAll{typeof(TEntity).Name}"); + throw; + } + } + + /// + /// Creates a new entity with automatic audit field and organization assignment. + /// Validates entity before creation and sets CreatedBy, CreatedOn, and OrganizationId. + /// + public virtual async Task CreateAsync(TEntity entity) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + // Validate entity + await ValidateEntityAsync(entity); + + // Ensure ID is set + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.NewGuid(); + } + + // Set audit fields + SetAuditFieldsForCreate(entity, userId); + + // Set organization ID if property exists + if (HasOrganizationIdProperty(entity) && organizationId.HasValue) + { + SetOrganizationId(entity, organizationId.Value); + } + + _dbSet.Add(entity); + await _context.SaveChangesAsync(); + + _logger.LogInformation($"{typeof(TEntity).Name} created: {entity.Id} by user {userId}"); + + return entity; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, $"Create{typeof(TEntity).Name}"); + throw; + } + } + + /// + /// Updates an existing entity with automatic audit field management. + /// Validates entity and organization ownership before update. + /// Sets LastModifiedBy and LastModifiedOn automatically. + /// + public virtual async Task UpdateAsync(TEntity entity) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + // Validate entity + await ValidateEntityAsync(entity); + + // Verify entity exists and belongs to organization + var existing = await _dbSet + .FirstOrDefaultAsync(e => e.Id == entity.Id && !e.IsDeleted); + + if (existing == null) + { + throw new InvalidOperationException($"{typeof(TEntity).Name} not found: {entity.Id}"); + } + + // Verify organization access + if (HasOrganizationIdProperty(existing) && organizationId.HasValue) + { + var existingOrgId = GetOrganizationId(existing); + if (existingOrgId != organizationId) + { + throw new UnauthorizedAccessException( + $"Cannot update {typeof(TEntity).Name} {entity.Id} - belongs to different organization."); + } + + // Prevent organization hijacking + SetOrganizationId(entity, organizationId.Value); + } + + // Set audit fields + SetAuditFieldsForUpdate(entity, userId); + + // Update entity + _context.Entry(existing).CurrentValues.SetValues(entity); + await _context.SaveChangesAsync(); + + _logger.LogInformation($"{typeof(TEntity).Name} updated: {entity.Id} by user {userId}"); + + return entity; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, $"Update{typeof(TEntity).Name}"); + throw; + } + } + + /// + /// Deletes an entity (soft delete if enabled, hard delete otherwise). + /// Verifies organization ownership before deletion. + /// + public virtual async Task DeleteAsync(Guid id) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var entity = await _dbSet + .FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted); + + if (entity == null) + { + _logger.LogWarning($"{typeof(TEntity).Name} not found for deletion: {id}"); + return false; + } + + // Verify organization access + if (HasOrganizationIdProperty(entity) && organizationId.HasValue) + { + var entityOrgId = GetOrganizationId(entity); + if (entityOrgId != organizationId) + { + throw new UnauthorizedAccessException( + $"Cannot delete {typeof(TEntity).Name} {id} - belongs to different organization."); + } + } + + // Soft delete or hard delete based on settings + if (_settings.SoftDeleteEnabled) + { + entity.IsDeleted = true; + SetAuditFieldsForUpdate(entity, userId); + await _context.SaveChangesAsync(); + _logger.LogInformation($"{typeof(TEntity).Name} soft deleted: {id} by user {userId}"); + } + else + { + _dbSet.Remove(entity); + await _context.SaveChangesAsync(); + _logger.LogInformation($"{typeof(TEntity).Name} hard deleted: {id} by user {userId}"); + } + + return true; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, $"Delete{typeof(TEntity).Name}"); + throw; + } + } + + #endregion + + #region Helper Methods + + /// + /// Virtual method for entity-specific validation. + /// Override in derived classes to implement custom validation logic. + /// + protected virtual async Task ValidateEntityAsync(TEntity entity) + { + // Default: no validation + // Override in derived classes for specific validation + await Task.CompletedTask; + } + + /// + /// Virtual method for centralized exception handling. + /// Override in derived classes for custom error handling logic. + /// + protected virtual async Task HandleExceptionAsync(Exception ex, string operation) + { + _logger.LogError(ex, $"Error in {operation} for {typeof(TEntity).Name}"); + await Task.CompletedTask; + } + + /// + /// Sets audit fields when creating a new entity. + /// + protected virtual void SetAuditFieldsForCreate(TEntity entity, string userId) + { + entity.CreatedBy = userId; + entity.CreatedOn = DateTime.UtcNow; + } + + /// + /// Sets audit fields when updating an existing entity. + /// + protected virtual void SetAuditFieldsForUpdate(TEntity entity, string userId) + { + entity.LastModifiedBy = userId; + entity.LastModifiedOn = DateTime.UtcNow; + } + + /// + /// Checks if entity has OrganizationId property via reflection. + /// + private bool HasOrganizationIdProperty(TEntity entity) + { + return typeof(TEntity).GetProperty("OrganizationId") != null; + } + + /// + /// Gets the OrganizationId value from entity via reflection. + /// + private Guid? GetOrganizationId(TEntity entity) + { + var property = typeof(TEntity).GetProperty("OrganizationId"); + if (property == null) return null; + + var value = property.GetValue(entity); + return value is Guid guidValue ? guidValue : null; + } + + /// + /// Sets the OrganizationId value on entity via reflection. + /// + private void SetOrganizationId(TEntity entity, Guid organizationId) + { + var property = typeof(TEntity).GetProperty("OrganizationId"); + property?.SetValue(entity, organizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Program.cs b/Aquiis.SimpleStart/Program.cs index c5d2d5d..f83000c 100644 --- a/Aquiis.SimpleStart/Program.cs +++ b/Aquiis.SimpleStart/Program.cs @@ -141,6 +141,7 @@ }); builder.Services.AddScoped(); +builder.Services.AddScoped(); // New refactored service builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); From f29d237ff3878bc8d17afb06b525bed1c7eaff82 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sat, 27 Dec 2025 17:52:03 -0600 Subject: [PATCH 3/9] See REVISIONS.md for details --- .../DocumentServiceTests.cs | 834 +++++++++++ .../InvoiceServiceTests.cs | 1009 ++++++++++++++ Aquiis.SimpleStart.Tests/LeaseServiceTests.cs | 933 +++++++++++++ .../MaintenanceServiceTests.cs | 1216 +++++++++++++++++ .../PaymentServiceTests.cs | 983 +++++++++++++ .../TenantServiceTests.cs | 865 ++++++++++++ .../Services/CalendarEventService.cs | 3 +- .../Application/Services/DocumentService.cs | 419 ++++++ .../Application/Services/InvoiceService.cs | 465 +++++++ .../Application/Services/LeaseService.cs | 492 +++++++ .../Services/MaintenanceService.cs | 492 +++++++ .../Application/Services/PaymentService.cs | 409 ++++++ .../Application/Services/TenantService.cs | 418 ++++++ .../Core/Interfaces/ICalendarEventService.cs | 21 + .../Checklists/Pages/View.razor | 2 +- Aquiis.SimpleStart/Program.cs | 8 +- 16 files changed, 8566 insertions(+), 3 deletions(-) create mode 100644 Aquiis.SimpleStart.Tests/DocumentServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/LeaseServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/PaymentServiceTests.cs create mode 100644 Aquiis.SimpleStart.Tests/TenantServiceTests.cs create mode 100644 Aquiis.SimpleStart/Application/Services/DocumentService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/InvoiceService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/LeaseService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/MaintenanceService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/PaymentService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/TenantService.cs create mode 100644 Aquiis.SimpleStart/Core/Interfaces/ICalendarEventService.cs diff --git a/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs b/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs new file mode 100644 index 0000000..4a81a69 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs @@ -0,0 +1,834 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using DocumentService = Aquiis.SimpleStart.Application.Services.DocumentService; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Comprehensive unit tests for DocumentService. + /// Tests CRUD operations, validation, business logic, and organization isolation. + /// + public class DocumentServiceTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly Mock> _mockLogger; + private readonly IOptions _mockSettings; + private readonly DocumentService _service; + private readonly Guid _testOrgId = Guid.NewGuid(); + private readonly string _testUserId = "test-user-123"; + private readonly Guid _testPropertyId = Guid.NewGuid(); + private readonly Guid _testTenantId = Guid.NewGuid(); + private readonly Guid _testLeaseId = Guid.NewGuid(); + + public DocumentServiceTests() + { + // Setup SQLite in-memory database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Mock AuthenticationStateProvider with claims + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Create test user + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + // Create test organization + var organization = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(organization); + + // Create test property + var property = new Property + { + Id = _testPropertyId, + OrganizationId = _testOrgId, + Address = "123 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + + // Create test tenant + var tenant = new Tenant + { + Id = _testTenantId, + OrganizationId = _testOrgId, + FirstName = "Test", + LastName = "Tenant", + Email = "tenant@test.com", + IdentificationNumber = "SSN123456", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + // Create test lease + var lease = new Lease + { + Id = _testLeaseId, + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + SecurityDeposit = 1500, + Status = "Active", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(lease); + + _context.SaveChanges(); + + // Setup logger and settings + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + // Create service instance + _service = new DocumentService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_ValidDocument_CreatesSuccessfully() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "TestDocument.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3, 4, 5 }, + ContentType = "application/pdf", + FileType = "PDF", + FileSize = 5, + DocumentType = "Lease Agreement", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _service.CreateAsync(document); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal("TestDocument.pdf", result.FileName); + } + + [Fact] + public async Task CreateAsync_MissingFileName_ThrowsException() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "", // Missing + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + DocumentType = "Invoice", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + [Fact] + public async Task CreateAsync_MissingFileExtension_ThrowsException() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "test.pdf", + FileExtension = "", // Missing + FileData = new byte[] { 1, 2, 3 }, + DocumentType = "Invoice", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + [Fact] + public async Task CreateAsync_MissingDocumentType_ThrowsException() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "test.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + DocumentType = "", // Missing + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + [Fact] + public async Task CreateAsync_MissingFileData_ThrowsException() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "test.pdf", + FileExtension = ".pdf", + FileData = Array.Empty(), // Missing + DocumentType = "Invoice", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + [Fact] + public async Task CreateAsync_NoForeignKeys_ThrowsException() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "test.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + DocumentType = "Invoice", + // No foreign keys set + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + [Fact] + public async Task CreateAsync_FileSizeExceedsLimit_ThrowsException() + { + // Arrange - Create 11MB file (exceeds 10MB limit) + var largeFile = new byte[11 * 1024 * 1024]; + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "large.pdf", + FileExtension = ".pdf", + FileData = largeFile, + FileSize = largeFile.Length, + DocumentType = "Invoice", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(document)); + } + + #endregion + + #region Retrieval Tests + + [Fact] + public async Task GetDocumentsByPropertyIdAsync_ReturnsPropertyDocuments() + { + // Arrange - Create documents + var doc1 = new Document + { + OrganizationId = _testOrgId, + FileName = "property_doc1.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Photo", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc1); + + var doc2 = new Document + { + OrganizationId = _testOrgId, + FileName = "property_doc2.jpg", + FileExtension = ".jpg", + FileData = new byte[] { 4, 5, 6 }, + FileSize = 3, + DocumentType = "Photo", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc2); + + // Act + var result = await _service.GetDocumentsByPropertyIdAsync(_testPropertyId); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, d => Assert.Equal(_testPropertyId, d.PropertyId)); + } + + [Fact] + public async Task GetDocumentsByTenantIdAsync_ReturnsTenantDocuments() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "tenant_id.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Identification", + TenantId = _testTenantId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(document); + + // Act + var result = await _service.GetDocumentsByTenantIdAsync(_testTenantId); + + // Assert + Assert.Single(result); + Assert.Equal(_testTenantId, result[0].TenantId); + } + + [Fact] + public async Task GetDocumentsByLeaseIdAsync_ReturnsLeaseDocuments() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "lease_agreement.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Lease Agreement", + LeaseId = _testLeaseId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(document); + + // Act + var result = await _service.GetDocumentsByLeaseIdAsync(_testLeaseId); + + // Assert + Assert.Single(result); + Assert.Equal(_testLeaseId, result[0].LeaseId); + } + + [Fact] + public async Task GetDocumentsByTypeAsync_ReturnsDocumentsOfType() + { + // Arrange - Create documents of different types + var leaseDoc = new Document + { + OrganizationId = _testOrgId, + FileName = "lease1.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Lease Agreement", + LeaseId = _testLeaseId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(leaseDoc); + + var photo = new Document + { + OrganizationId = _testOrgId, + FileName = "photo.jpg", + FileExtension = ".jpg", + FileData = new byte[] { 4, 5, 6 }, + FileSize = 3, + DocumentType = "Photo", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(photo); + + // Act + var leaseResults = await _service.GetDocumentsByTypeAsync("Lease Agreement"); + var photoResults = await _service.GetDocumentsByTypeAsync("Photo"); + + // Assert + Assert.Single(leaseResults); + Assert.Single(photoResults); + Assert.Equal("Lease Agreement", leaseResults[0].DocumentType); + Assert.Equal("Photo", photoResults[0].DocumentType); + } + + [Fact] + public async Task SearchDocumentsByFilenameAsync_FindsMatchingDocuments() + { + // Arrange + var doc1 = new Document + { + OrganizationId = _testOrgId, + FileName = "lease_agreement_2025.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Lease Agreement", + LeaseId = _testLeaseId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc1); + + var doc2 = new Document + { + OrganizationId = _testOrgId, + FileName = "property_photo.jpg", + FileExtension = ".jpg", + FileData = new byte[] { 4, 5, 6 }, + FileSize = 3, + DocumentType = "Photo", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc2); + + // Act + var leaseResults = await _service.SearchDocumentsByFilenameAsync("lease"); + var photoResults = await _service.SearchDocumentsByFilenameAsync("photo"); + + // Assert + Assert.Single(leaseResults); + Assert.Single(photoResults); + Assert.Contains("lease", leaseResults[0].FileName.ToLower()); + Assert.Contains("photo", photoResults[0].FileName.ToLower()); + } + + [Fact] + public async Task SearchDocumentsByFilenameAsync_EmptySearch_ReturnsRecentDocuments() + { + // Arrange - Create documents directly in database to control CreatedOn + for (int i = 0; i < 3; i++) + { + var doc = new Document + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FileName = $"doc{i}.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow.AddMinutes(i), // doc2 is most recent, doc0 is oldest + IsDeleted = false + }; + _context.Documents.Add(doc); + } + await _context.SaveChangesAsync(); + + // Act + var result = await _service.SearchDocumentsByFilenameAsync(""); + + // Assert + Assert.Equal(3, result.Count); + // Should be ordered by most recent first (descending CreatedOn) + Assert.Equal("doc2.pdf", result[0].FileName); // Most recent + Assert.Equal("doc1.pdf", result[1].FileName); + Assert.Equal("doc0.pdf", result[2].FileName); // Oldest + } + + [Fact] + public async Task GetDocumentWithRelationsAsync_LoadsAllRelations() + { + // Arrange + var document = new Document + { + OrganizationId = _testOrgId, + FileName = "full_relations.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Lease Agreement", + PropertyId = _testPropertyId, + TenantId = _testTenantId, + LeaseId = _testLeaseId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(document); + + // Act + var result = await _service.GetDocumentWithRelationsAsync(created.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Property); + Assert.NotNull(result.Tenant); + Assert.NotNull(result.Lease); + Assert.Equal(_testPropertyId, result.Property.Id); + Assert.Equal(_testTenantId, result.Tenant!.Id); + Assert.Equal(_testLeaseId, result.Lease!.Id); + } + + #endregion + + #region Business Logic Tests + + [Fact] + public async Task CalculateTotalStorageUsedAsync_ReturnsCorrectTotal() + { + // Arrange - Create documents with known sizes + var doc1 = new Document + { + OrganizationId = _testOrgId, + FileName = "doc1.pdf", + FileExtension = ".pdf", + FileData = new byte[1000], + FileSize = 1000, + DocumentType = "Test", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc1); + + var doc2 = new Document + { + OrganizationId = _testOrgId, + FileName = "doc2.jpg", + FileExtension = ".jpg", + FileData = new byte[2500], + FileSize = 2500, + DocumentType = "Photo", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc2); + + // Act + var totalStorage = await _service.CalculateTotalStorageUsedAsync(); + + // Assert + Assert.Equal(3500, totalStorage); + } + + [Fact] + public async Task GetDocumentsByDateRangeAsync_ReturnsDocumentsInRange() + { + // Arrange - Create documents at different times + var oldDoc = new Document + { + OrganizationId = _testOrgId, + FileName = "old.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow.AddMonths(-2) + }; + _context.Documents.Add(oldDoc); + + var recentDoc = new Document + { + OrganizationId = _testOrgId, + FileName = "recent.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 4, 5, 6 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(recentDoc); + + // Act + var startDate = DateTime.UtcNow.AddDays(-7); + var endDate = DateTime.UtcNow.AddDays(1); + var result = await _service.GetDocumentsByDateRangeAsync(startDate, endDate); + + // Assert + Assert.Single(result); + Assert.Equal("recent.pdf", result[0].FileName); + } + + [Fact] + public async Task GetDocumentCountByTypeAsync_ReturnsCorrectCounts() + { + // Arrange - Create documents of various types + var types = new[] { "Lease Agreement", "Lease Agreement", "Photo", "Invoice" }; + foreach (var type in types) + { + var doc = new Document + { + OrganizationId = _testOrgId, + FileName = $"{type}.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = type, + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(doc); + } + + // Act + var counts = await _service.GetDocumentCountByTypeAsync(); + + // Assert + Assert.Equal(2, counts["Lease Agreement"]); + Assert.Equal(1, counts["Photo"]); + Assert.Equal(1, counts["Invoice"]); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange - Create different organization and document + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherOrgDoc = new Document + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FileName = "other_doc.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = otherProperty.Id, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Documents.AddAsync(otherOrgDoc); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherOrgDoc.Id); + + // Assert + Assert.Null(result); // Should not access document from different org + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationDocuments() + { + // Arrange - Create document in test org + var testOrgDoc = new Document + { + OrganizationId = _testOrgId, + FileName = "test_org.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 1, 2, 3 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = _testPropertyId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(testOrgDoc); + + // Create document in different org + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "888 Other Ave", + City = "Other City", + State = "OT", + ZipCode = "88888", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherOrgDoc = new Document + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FileName = "other.pdf", + FileExtension = ".pdf", + FileData = new byte[] { 4, 5, 6 }, + FileSize = 3, + DocumentType = "Test", + PropertyId = otherProperty.Id, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Documents.AddAsync(otherOrgDoc); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(_testOrgId, result[0].OrganizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs b/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs new file mode 100644 index 0000000..3b44833 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs @@ -0,0 +1,1009 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using InvoiceService = Aquiis.SimpleStart.Application.Services.InvoiceService; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Comprehensive unit tests for InvoiceService. + /// Tests CRUD operations, validation, business logic, and organization isolation. + /// + public class InvoiceServiceTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly Mock> _mockLogger; + private readonly IOptions _mockSettings; + private readonly InvoiceService _service; + private readonly Guid _testOrgId = Guid.NewGuid(); + private readonly string _testUserId = "test-user-123"; + private readonly Guid _testPropertyId = Guid.NewGuid(); + private readonly Guid _testTenantId = Guid.NewGuid(); + private readonly Guid _testLeaseId = Guid.NewGuid(); + + public InvoiceServiceTests() + { + // Setup SQLite in-memory database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Mock AuthenticationStateProvider with claims + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Create test user + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + // Create test organization + var organization = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(organization); + + // Create test property + var property = new Property + { + Id = _testPropertyId, + OrganizationId = _testOrgId, + Address = "123 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = false, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + + // Create test tenant + var tenant = new Tenant + { + Id = _testTenantId, + OrganizationId = _testOrgId, + FirstName = "Test", + LastName = "Tenant", + Email = "tenant@test.com", + IdentificationNumber = "SSN123456", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + // Create test lease + var lease = new Lease + { + Id = _testLeaseId, + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + SecurityDeposit = 1500, + Status = "Active", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(lease); + + _context.SaveChanges(); + + // Setup logger and settings + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + // Create service instance + _service = new InvoiceService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_ValidInvoice_CreatesSuccessfully() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-202512-00001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Monthly Rent - December 2025", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _service.CreateAsync(invoice); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal("INV-202512-00001", result.InvoiceNumber); + Assert.Equal(1500, result.Amount); + } + + [Fact] + public async Task CreateAsync_MissingLeaseId_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = Guid.Empty, // Missing + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + [Fact] + public async Task CreateAsync_MissingInvoiceNumber_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "", // Missing + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + [Fact] + public async Task CreateAsync_MissingDescription_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "", // Missing + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + [Fact] + public async Task CreateAsync_ZeroAmount_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 0, // Invalid + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + [Fact] + public async Task CreateAsync_DueBeforeInvoiced_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(-1), // Before invoice date + Amount = 1500, + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + [Fact] + public async Task CreateAsync_DuplicateInvoiceNumber_ThrowsException() + { + // Arrange + var invoice1 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-DUPLICATE", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(invoice1); + + var invoice2 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-DUPLICATE", // Same number + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice2)); + } + + [Fact] + public async Task CreateAsync_InvalidStatus_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + Status = "InvalidStatus", // Invalid + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(invoice)); + } + + #endregion + + #region Retrieval Tests + + [Fact] + public async Task GetInvoicesByLeaseIdAsync_ReturnsLeaseInvoices() + { + // Arrange - Create invoices + var invoice1 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Rent - Month 1", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(invoice1); + + var invoice2 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-002", + InvoicedOn = DateTime.Today.AddMonths(1), + DueOn = DateTime.Today.AddMonths(1).AddDays(30), + Amount = 1500, + Description = "Rent - Month 2", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(invoice2); + + // Act + var result = await _service.GetInvoicesByLeaseIdAsync(_testLeaseId); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, i => Assert.Equal(_testLeaseId, i.LeaseId)); + } + + [Fact] + public async Task GetInvoicesByStatusAsync_ReturnsMatchingInvoices() + { + // Arrange + var pending = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-PENDING", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Pending Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(pending); + + var paid = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-PAID", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Paid Invoice", + Status = "Paid", + PaidOn = DateTime.Today, + AmountPaid = 1500, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(paid); + + // Act + var pendingResults = await _service.GetInvoicesByStatusAsync("Pending"); + var paidResults = await _service.GetInvoicesByStatusAsync("Paid"); + + // Assert + Assert.Single(pendingResults); + Assert.Single(paidResults); + Assert.Equal("Pending", pendingResults[0].Status); + Assert.Equal("Paid", paidResults[0].Status); + } + + [Fact] + public async Task GetOverdueInvoicesAsync_ReturnsOnlyOverdueInvoices() + { + // Arrange - Create overdue invoice + var overdue = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-OVERDUE", + InvoicedOn = DateTime.Today.AddDays(-60), + DueOn = DateTime.Today.AddDays(-30), // 30 days overdue + Amount = 1500, + Description = "Overdue Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(overdue); + + // Create current invoice (not overdue) + var current = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-CURRENT", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Current Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(current); + + // Act + var result = await _service.GetOverdueInvoicesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal("INV-OVERDUE", result[0].InvoiceNumber); + Assert.True(result[0].DueOn < DateTime.Today); + } + + [Fact] + public async Task GetInvoicesDueSoonAsync_ReturnsInvoicesDueWithinThreshold() + { + // Arrange - Create invoice due in 5 days + var dueSoon = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-DUE-SOON", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(5), + Amount = 1500, + Description = "Due Soon", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(dueSoon); + + // Create invoice due in 30 days (outside threshold) + var dueLater = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-DUE-LATER", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Due Later", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(dueLater); + + // Act + var result = await _service.GetInvoicesDueSoonAsync(7); + + // Assert + Assert.Single(result); + Assert.Equal("INV-DUE-SOON", result[0].InvoiceNumber); + } + + [Fact] + public async Task GetInvoiceWithRelationsAsync_LoadsAllRelations() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-RELATIONS", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test Relations", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(invoice); + + // Act + var result = await _service.GetInvoiceWithRelationsAsync(created.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Lease); + Assert.NotNull(result.Lease.Property); + Assert.NotNull(result.Lease.Tenant); + Assert.Equal(_testLeaseId, result.Lease.Id); + } + + #endregion + + #region Business Logic Tests + + [Fact] + public async Task GenerateInvoiceNumberAsync_GeneratesUniqueNumber() + { + // Act + var invoiceNumber1 = await _service.GenerateInvoiceNumberAsync(); + var invoiceNumber2 = await _service.GenerateInvoiceNumberAsync(); + + // Assert + Assert.NotNull(invoiceNumber1); + Assert.NotNull(invoiceNumber2); + Assert.StartsWith("INV-", invoiceNumber1); + Assert.StartsWith("INV-", invoiceNumber2); + // Numbers should be same format but potentially different sequence + Assert.Matches(@"^INV-\d{6}-\d{5}$", invoiceNumber1); + } + + [Fact] + public async Task ApplyLateFeeAsync_AddsLateFeeToInvoice() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-LATE-FEE", + InvoicedOn = DateTime.Today.AddDays(-60), + DueOn = DateTime.Today.AddDays(-30), + Amount = 1500, + Description = "Overdue Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(invoice); + + // Act + var result = await _service.ApplyLateFeeAsync(created.Id, 50m); + + // Assert + Assert.Equal(50m, result.LateFeeAmount); + Assert.True(result.LateFeeApplied); + Assert.NotNull(result.LateFeeAppliedOn); + Assert.Equal("Overdue", result.Status); + } + + [Fact] + public async Task ApplyLateFeeAsync_AlreadyApplied_ThrowsException() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-TEST", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test", + Status = "Pending", + LateFeeApplied = true, + LateFeeAmount = 50, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(invoice); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.ApplyLateFeeAsync(created.Id, 50m)); + } + + [Fact] + public async Task MarkReminderSentAsync_UpdatesReminderFields() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-REMINDER", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(5), + Amount = 1500, + Description = "Test Reminder", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(invoice); + + // Act + var result = await _service.MarkReminderSentAsync(created.Id); + + // Assert + Assert.True(result.ReminderSent); + Assert.NotNull(result.ReminderSentOn); + } + + [Fact] + public async Task UpdateInvoiceStatusAsync_FullyPaid_UpdatesStatusToPaid() + { + // Arrange + var invoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-STATUS-TEST", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test Status", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(invoice); + + // Create payment + var payment = new Payment + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + InvoiceId = created.Id, + Amount = 1500, + PaymentMethod = "Check", + PaidOn = DateTime.Today, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow, + IsDeleted = false + }; + _context.Payments.Add(payment); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.UpdateInvoiceStatusAsync(created.Id); + + // Assert + Assert.Equal("Paid", result.Status); + Assert.Equal(1500, result.AmountPaid); + Assert.NotNull(result.PaidOn); + } + + [Fact] + public async Task CalculateTotalOutstandingAsync_ReturnsCorrectTotal() + { + // Arrange - Create unpaid invoices + var invoice1 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-OUT-1", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Outstanding 1", + Status = "Pending", + AmountPaid = 0, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(invoice1); + + var invoice2 = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-OUT-2", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 2000, + Description = "Outstanding 2", + Status = "Pending", + AmountPaid = 500, // Partially paid + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(invoice2); + + // Act + var total = await _service.CalculateTotalOutstandingAsync(); + + // Assert + Assert.Equal(3000m, total); // 1500 + (2000 - 500) + } + + [Fact] + public async Task GetInvoicesByDateRangeAsync_ReturnsInvoicesInRange() + { + // Arrange + var oldInvoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-OLD", + InvoicedOn = DateTime.Today.AddMonths(-2), + DueOn = DateTime.Today.AddMonths(-1), + Amount = 1500, + Description = "Old Invoice", + Status = "Paid", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(oldInvoice); + + var recentInvoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-RECENT", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Recent Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(recentInvoice); + + // Act + var startDate = DateTime.Today.AddDays(-7); + var endDate = DateTime.Today.AddDays(7); + var result = await _service.GetInvoicesByDateRangeAsync(startDate, endDate); + + // Assert + Assert.Single(result); + Assert.Equal("INV-RECENT", result[0].InvoiceNumber); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange - Create different organization and invoice + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Tenant", + Email = "other@test.com", + IdentificationNumber = "ID999", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2000, + SecurityDeposit = 2000, + Status = "Active", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherLease); + + var otherOrgInvoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + LeaseId = otherLease.Id, + InvoiceNumber = "INV-OTHER", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 2000, + Description = "Other Org Invoice", + Status = "Pending", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Invoices.AddAsync(otherOrgInvoice); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherOrgInvoice.Id); + + // Assert + Assert.Null(result); // Should not access invoice from different org + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationInvoices() + { + // Arrange - Create invoice in test org + var testOrgInvoice = new Invoice + { + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-TEST-ORG", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test Org Invoice", + Status = "Pending", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(testOrgInvoice); + + // Create invoice in different org + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "888 Other Ave", + City = "Other City", + State = "OT", + ZipCode = "88888", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Tenant", + Email = "other@test.com", + IdentificationNumber = "ID888", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2500, + SecurityDeposit = 2500, + Status = "Active", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherLease); + + var otherOrgInvoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + LeaseId = otherLease.Id, + InvoiceNumber = "INV-OTHER-ORG", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 2500, + Description = "Other", + Status = "Pending", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Invoices.AddAsync(otherOrgInvoice); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(_testOrgId, result[0].OrganizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs b/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs new file mode 100644 index 0000000..09cefb2 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs @@ -0,0 +1,933 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Application.Services; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Comprehensive unit tests for LeaseService. + /// Tests CRUD operations, business logic, validation, and organization isolation. + /// + public class LeaseServiceTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly Mock> _mockLogger; + private readonly IOptions _mockSettings; + private readonly LeaseService _service; + private readonly Guid _testOrgId = Guid.NewGuid(); + private readonly string _testUserId = "test-user-123"; + private readonly Guid _testPropertyId = Guid.NewGuid(); + private readonly Guid _testTenantId = Guid.NewGuid(); + + public LeaseServiceTests() + { + // Setup SQLite in-memory database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Mock AuthenticationStateProvider with claims + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Create test user + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + // Create test organization + var organization = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(organization); + + // Create test property + var property = new Property + { + Id = _testPropertyId, + OrganizationId = _testOrgId, + Address = "123 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + + // Create test tenant + var tenant = new Tenant + { + Id = _testTenantId, + OrganizationId = _testOrgId, + FirstName = "Test", + LastName = "Tenant", + Email = "tenant@test.com", + IdentificationNumber = "SSN123456", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + _context.SaveChanges(); + + // Setup logger and settings + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + // Create service instance + _service = new LeaseService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_ValidLease_CreatesSuccessfully() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + SecurityDeposit = 1500, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _service.CreateAsync(lease); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal(_testPropertyId, result.PropertyId); + Assert.Equal(_testTenantId, result.TenantId); + } + + [Fact] + public async Task CreateAsync_MissingPropertyId_ThrowsException() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = Guid.Empty, // Missing + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(lease)); + } + + [Fact] + public async Task CreateAsync_MissingTenantId_ThrowsException() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = Guid.Empty, // Missing + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(lease)); + } + + [Fact] + public async Task CreateAsync_EndDateBeforeStartDate_ThrowsException() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddYears(1), + EndDate = DateTime.Today, // Before start date + MonthlyRent = 1500, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(lease)); + } + + [Fact] + public async Task CreateAsync_ZeroMonthlyRent_ThrowsException() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 0, // Invalid + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(lease)); + } + + [Fact] + public async Task CreateAsync_OverlappingLease_ThrowsException() + { + // Arrange - Create first lease + var firstLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(firstLease); + + // Try to create overlapping lease + var overlappingLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddMonths(6), + EndDate = DateTime.Today.AddMonths(18), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(overlappingLease)); + } + + #endregion + + #region Property Availability Tests + + [Fact] + public async Task CreateAsync_ActiveLease_MarksPropertyUnavailable() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + await _service.CreateAsync(lease); + + // Assert + var property = await _context.Properties.FindAsync(_testPropertyId); + Assert.False(property!.IsAvailable); + } + + [Fact] + public async Task CreateAsync_PendingLease_DoesNotMarkPropertyUnavailable() + { + // Arrange + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + await _service.CreateAsync(lease); + + // Assert + var property = await _context.Properties.FindAsync(_testPropertyId); + Assert.True(property!.IsAvailable); + } + + [Fact] + public async Task DeleteAsync_ActiveLease_MarksPropertyAvailable() + { + // Arrange - Create active lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(lease); + + // Act + await _service.DeleteAsync(created.Id); + + // Assert + var property = await _context.Properties.FindAsync(_testPropertyId); + Assert.True(property!.IsAvailable); + } + + #endregion + + #region Retrieval Tests + + [Fact] + public async Task GetLeasesByPropertyIdAsync_ReturnsPropertyLeases() + { + // Arrange - Create multiple leases + var lease1 = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddYears(-1), + EndDate = DateTime.Today.AddMonths(-1), + MonthlyRent = 1200, + Status = ApplicationConstants.LeaseStatuses.Expired, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(lease1); + + var lease2 = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(lease2); + + // Act + var result = await _service.GetLeasesByPropertyIdAsync(_testPropertyId); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, l => Assert.Equal(_testPropertyId, l.PropertyId)); + } + + [Fact] + public async Task GetLeasesByTenantIdAsync_ReturnsTenantLeases() + { + // Arrange - Create lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(lease); + + // Act + var result = await _service.GetLeasesByTenantIdAsync(_testTenantId); + + // Assert + Assert.Single(result); + Assert.Equal(_testTenantId, result[0].TenantId); + } + + [Fact] + public async Task GetActiveLeasesAsync_ReturnsOnlyActiveLeases() + { + // Arrange - Create leases with different statuses + var pendingLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddMonths(1), + EndDate = DateTime.Today.AddMonths(13), + MonthlyRent = 1400, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(pendingLease); + + // Create a second property and tenant for active lease + var property2 = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "456 Test Ave", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property2); + + var tenant2 = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FirstName = "Active", + LastName = "Tenant", + Email = "active@test.com", + IdentificationNumber = "SSN789012", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant2); + await _context.SaveChangesAsync(); + + var activeLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = property2.Id, + TenantId = tenant2.Id, + StartDate = DateTime.Today.AddMonths(-1), + EndDate = DateTime.Today.AddMonths(11), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(activeLease); + + // Act + var result = await _service.GetActiveLeasesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(ApplicationConstants.LeaseStatuses.Active, result[0].Status); + Assert.True(result[0].StartDate <= DateTime.Today); + Assert.True(result[0].EndDate >= DateTime.Today); + } + + [Fact] + public async Task GetLeasesExpiringSoonAsync_ReturnsLeasesWithinThreshold() + { + // Arrange - Create leases with different expiration dates + // Create additional property + var property2 = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "789 Test Blvd", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property2); + + var tenant2 = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + FirstName = "Expiring", + LastName = "Tenant", + Email = "expiring@test.com", + IdentificationNumber = "SSN345678", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant2); + await _context.SaveChangesAsync(); + + // Lease expiring in 30 days (within 90-day threshold) + var expiringSoonLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddYears(-1), + EndDate = DateTime.Today.AddDays(30), + MonthlyRent = 1400, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(expiringSoonLease); + + // Lease expiring in 6 months (outside 90-day threshold) + var farOutLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = property2.Id, + TenantId = tenant2.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddMonths(6), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(farOutLease); + + // Act + var result = await _service.GetLeasesExpiringSoonAsync(90); + + // Assert + Assert.Single(result); + Assert.Equal(expiringSoonLease.Id, result[0].Id); + } + + [Fact] + public async Task GetLeasesByStatusAsync_ReturnsLeasesWithStatus() + { + // Arrange - Create leases with different statuses + var activeLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(activeLease); + + // Create second property for pending lease + var property2 = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "321 Pending Rd", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = true, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property2); + await _context.SaveChangesAsync(); + + var pendingLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = property2.Id, + TenantId = _testTenantId, + StartDate = DateTime.Today.AddMonths(1), + EndDate = DateTime.Today.AddMonths(13), + MonthlyRent = 1400, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(pendingLease); + + // Act + var activeResults = await _service.GetLeasesByStatusAsync(ApplicationConstants.LeaseStatuses.Active); + var pendingResults = await _service.GetLeasesByStatusAsync(ApplicationConstants.LeaseStatuses.Pending); + + // Assert + Assert.Single(activeResults); + Assert.Single(pendingResults); + Assert.Equal(ApplicationConstants.LeaseStatuses.Active, activeResults[0].Status); + Assert.Equal(ApplicationConstants.LeaseStatuses.Pending, pendingResults[0].Status); + } + + [Fact] + public async Task GetLeaseWithRelationsAsync_LoadsAllRelations() + { + // Arrange - Create lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(lease); + + // Act + var result = await _service.GetLeaseWithRelationsAsync(created.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Property); + Assert.NotNull(result.Tenant); + Assert.Equal(_testPropertyId, result.Property.Id); + Assert.Equal(_testTenantId, result.Tenant!.Id); + } + + #endregion + + #region Business Logic Tests + + [Fact] + public async Task CalculateTotalLeaseValueAsync_CalculatesCorrectly() + { + // Arrange - Create 12-month lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = new DateTime(2025, 1, 1), + EndDate = new DateTime(2025, 12, 31), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(lease); + + // Act + var totalValue = await _service.CalculateTotalLeaseValueAsync(created.Id); + + // Assert + // 12 months * $1500 = $18,000 + Assert.Equal(18000, totalValue); + } + + [Fact] + public async Task UpdateLeaseStatusAsync_UpdatesStatusAndPropertyAvailability() + { + // Arrange - Create pending lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Pending, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(lease); + + // Act - Change to Active + var updated = await _service.UpdateLeaseStatusAsync(created.Id, ApplicationConstants.LeaseStatuses.Active); + + // Assert + Assert.Equal(ApplicationConstants.LeaseStatuses.Active, updated.Status); + var property = await _context.Properties.FindAsync(_testPropertyId); + Assert.False(property!.IsAvailable); + } + + [Fact] + public async Task UpdateLeaseStatusAsync_ToTerminated_MarksPropertyAvailable() + { + // Arrange - Create active lease + var lease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(lease); + + // Act - Change to Terminated + await _service.UpdateLeaseStatusAsync(created.Id, ApplicationConstants.LeaseStatuses.Terminated); + + // Assert + var property = await _context.Properties.FindAsync(_testPropertyId); + Assert.True(property!.IsAvailable); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange - Create different organization and lease + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Tenant", + Email = "other@example.com", + IdentificationNumber = "SSN999999", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherOrgLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2000, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherOrgLease); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherOrgLease.Id); + + // Assert + Assert.Null(result); // Should not access lease from different org + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationLeases() + { + // Arrange - Create lease in test org + var testOrgLease = new Lease + { + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(testOrgLease); + + // Create lease in different org + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "888 Other Ave", + City = "Other City", + State = "OT", + ZipCode = "88888", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Person", + Email = "otherperson@example.com", + IdentificationNumber = "SSN888888", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherOrgLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2000, + Status = ApplicationConstants.LeaseStatuses.Active, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherOrgLease); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(_testOrgId, result[0].Property.OrganizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs new file mode 100644 index 0000000..3b0ea46 --- /dev/null +++ b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs @@ -0,0 +1,1216 @@ +using Aquiis.SimpleStart.Application.Services; +using Aquiis.SimpleStart.Application.Services.Workflows; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Xunit; + +namespace Aquiis.SimpleStart.Tests +{ + public class MaintenanceServiceTests : IDisposable + { + private readonly ApplicationDbContext _context; + private readonly MaintenanceService _service; + private readonly Mock> _mockUserManager; + private readonly Mock> _mockLogger; + private readonly Mock _mockCalendarEventService; + private readonly UserContextService _userContext; + private readonly IOptions _mockSettings; + private readonly SqliteConnection _connection; + private readonly ApplicationUser _testUser; + private readonly Organization _testOrg; + + private readonly Guid _testOrgId = Guid.NewGuid(); + + private readonly string _testUserId = Guid.NewGuid().ToString(); + + private readonly Guid _testPropertyId = Guid.NewGuid(); + + private readonly Guid _testLeaseId = Guid.NewGuid(); + + private readonly Guid _testTenantId = Guid.NewGuid(); + + private readonly Property _testProperty; + private readonly Lease _testLease; + + private readonly Tenant _testTenant; + + public MaintenanceServiceTests() + { + // Create in-memory SQLite database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Setup test data + _testUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser@test.com", + Email = "testuser@test.com" + }; + + _testOrg = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + _testProperty = new Property + { + Id = _testPropertyId, + OrganizationId = _testOrg.Id, + Address = "123 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Single Family", + Bedrooms = 3, + Bathrooms = 2, + SquareFeet = 1500, + IsAvailable = true, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + + _testTenant = new Tenant + { + Id = _testTenantId, + OrganizationId = _testOrg.Id, + FirstName = "Test", + LastName = "Tenant", + Email = "testtenant@test.com", + PhoneNumber = "123-456-7890", + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + _testLease = new Lease + { + Id = _testLeaseId, + PropertyId = _testProperty.Id, + OrganizationId = _testOrg.Id, + TenantId = _testTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddMonths(12), + MonthlyRent = 1500, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + + _context.Users.Add(_testUser); + _context.SaveChanges(); // Save user first so OwnerId foreign key can be satisfied + + _context.Organizations.Add(_testOrg); + _context.Properties.Add(_testProperty); + _context.Tenants.Add(_testTenant); + _context.Leases.Add(_testLease); + _context.SaveChanges(); + + // Setup mocks + var userStore = new Mock>(); + _mockUserManager = new Mock>( + userStore.Object, null, null, null, null, null, null, null, null); + + _testUser.ActiveOrganizationId = _testOrg.Id; + _mockUserManager.Setup(x => x.FindByIdAsync(It.IsAny())) + .ReturnsAsync(_testUser); + + var mockAuthStateProvider = new Mock(); + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, _testUser.Id), + new Claim("OrganizationId", _testOrg.Id.ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + var claimsPrincipal = new ClaimsPrincipal(identity); + mockAuthStateProvider.Setup(x => x.GetAuthenticationStateAsync()) + .ReturnsAsync(new Microsoft.AspNetCore.Components.Authorization.AuthenticationState(claimsPrincipal)); + + var serviceProvider = new Mock(); + _userContext = new UserContextService( + mockAuthStateProvider.Object, + _mockUserManager.Object, + serviceProvider.Object); + + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + _mockCalendarEventService = new Mock(); + + _service = new MaintenanceService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings, + _mockCalendarEventService.Object); + } + + public void Dispose() + { + _context.Database.EnsureDeleted(); + _context.Dispose(); + _connection.Close(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_MissingPropertyId_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = Guid.Empty, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_MissingTitle_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_MissingDescription_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_InvalidPriority_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = "InvalidPriority", + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_InvalidStatus_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = "InvalidStatus", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_FutureRequestedDate_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today.AddDays(1), + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_ScheduledBeforeRequested_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + ScheduledOn = DateTime.Today.AddDays(-1), + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_NegativeEstimatedCost_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testPropertyId, + LeaseId = _testLeaseId, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + EstimatedCost = -100, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_CompletedWithoutDate_ThrowsException() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today, + CompletedOn = null, + OrganizationId = _testOrg.Id + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_InvalidPropertyOrganization_ThrowsException() + { + // Arrange + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Org", + OwnerId = _testUserId, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(otherOrg); + await _context.SaveChangesAsync(); + + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Test Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = otherOrg.Id // Different org than property + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest)); + } + + [Fact] + public async Task CreateAsync_ValidMaintenanceRequest_CreatesSuccessfully() + { + // Arrange + var maintenanceRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Leaky Faucet", + Description = "Kitchen faucet is dripping", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + EstimatedCost = 150, + OrganizationId = _testOrg.Id + }; + + // Act + var result = await _service.CreateAsync(maintenanceRequest); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal("Leaky Faucet", result.Title); + Assert.Equal(_testOrg.Id, result.OrganizationId); + + // Verify calendar event was created + _mockCalendarEventService.Verify( + x => x.CreateOrUpdateEventAsync(It.IsAny()), + Times.Once); + } + + #endregion + + #region Retrieval Tests + + [Fact] + public async Task GetMaintenanceRequestsByPropertyAsync_ReturnsPropertyRequests() + { + // Arrange + var request1 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 1", + Description = "Description 1", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var request2 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 2", + Description = "Description 2", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today.AddDays(-1), + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(request1); + await _service.CreateAsync(request2); + + // Act + var results = await _service.GetMaintenanceRequestsByPropertyAsync(_testProperty.Id); + + // Assert + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.Equal(_testProperty.Id, r.PropertyId)); + } + + [Fact] + public async Task GetMaintenanceRequestsByStatusAsync_ReturnsMatchingRequests() + { + // Arrange + var submitted = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Submitted Request", + Description = "Description", + RequestType = "Plumbing", + Priority = "Medium", + Status = "Submitted", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var inProgress = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "In Progress Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(submitted); + await _service.CreateAsync(inProgress); + + // Act + var results = await _service.GetMaintenanceRequestsByStatusAsync("Submitted"); + + // Assert + Assert.Single(results); + Assert.Equal("Submitted", results[0].Status); + } + + [Fact] + public async Task GetMaintenanceRequestsByPriorityAsync_ReturnsMatchingRequests() + { + // Arrange + var urgent = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Urgent Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var low = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Low Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Other, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Low, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(urgent); + await _service.CreateAsync(low); + + // Act + var results = await _service.GetMaintenanceRequestsByPriorityAsync("Urgent"); + + // Assert + Assert.Single(results); + Assert.Equal("Urgent", results[0].Priority); + } + + [Fact] + public async Task GetOverdueMaintenanceRequestsAsync_ReturnsOverdueRequests() + { + // Arrange + var overdue = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Overdue Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today.AddDays(-5), + ScheduledOn = DateTime.Today.AddDays(-2), + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var notOverdue = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Not Overdue Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + ScheduledOn = DateTime.Today.AddDays(2), + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(overdue); + await _service.CreateAsync(notOverdue); + + // Act + var results = await _service.GetOverdueMaintenanceRequestsAsync(); + + // Assert + Assert.Single(results); + Assert.Equal("Overdue Request", results[0].Title); + } + + [Fact] + public async Task GetMaintenanceRequestWithRelationsAsync_LoadsAllRelations() + { + // Arrange + // Tenant and Lease already exist from constructor, no need to re-add them + + var request = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + LeaseId = _testLease.Id, + Title = "Test Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(request); + + // Act + var result = await _service.GetMaintenanceRequestWithRelationsAsync(request.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Property); + Assert.NotNull(result.Lease); + Assert.NotNull(result.Lease.Tenant); + Assert.Equal("Test", result.Lease.Tenant.FirstName); + } + + #endregion + + #region Business Logic Tests + + [Fact] + public async Task UpdateMaintenanceRequestStatusAsync_UpdatesStatusAndSetsCompletedDate() + { + // Arrange + var request = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(request); + + // Act + var result = await _service.UpdateMaintenanceRequestStatusAsync(request.Id, "Completed"); + + // Assert + Assert.Equal("Completed", result.Status); + Assert.NotNull(result.CompletedOn); + Assert.Equal(DateTime.Today, result.CompletedOn.Value.Date); + } + + [Fact] + public async Task AssignMaintenanceRequestAsync_UpdatesAssignmentAndStatus() + { + // Arrange + var request = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(request); + + // Act + var scheduledDate = DateTime.Today.AddDays(2); + var result = await _service.AssignMaintenanceRequestAsync( + request.Id, + "John Smith", + scheduledDate); + + // Assert + Assert.Equal("John Smith", result.AssignedTo); + Assert.Equal(scheduledDate, result.ScheduledOn); + Assert.Equal("In Progress", result.Status); + } + + [Fact] + public async Task CompleteMaintenanceRequestAsync_UpdatesAllCompletionFields() + { + // Arrange + var request = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today.AddDays(-3), + EstimatedCost = 200, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(request); + + // Act + var result = await _service.CompleteMaintenanceRequestAsync( + request.Id, + 175.50m, + "Fixed the leak and replaced washers"); + + // Assert + Assert.Equal("Completed", result.Status); + Assert.Equal(175.50m, result.ActualCost); + Assert.Equal("Fixed the leak and replaced washers", result.ResolutionNotes); + Assert.NotNull(result.CompletedOn); + } + + [Fact] + public async Task GetOpenMaintenanceRequestCountAsync_ReturnsCorrectCount() + { + // Arrange + var submitted = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Submitted", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var inProgress = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "In Progress", + Description = "Description", + RequestType = "Electrical", + Priority = "High", + Status = "In Progress", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var completed = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Completed", + Description = "Description", + RequestType = "Other", + Priority = "Low", + Status = "Completed", + RequestedOn = DateTime.Today.AddDays(-5), + CompletedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(submitted); + await _service.CreateAsync(inProgress); + await _service.CreateAsync(completed); + + // Act + var count = await _service.GetOpenMaintenanceRequestCountAsync(); + + // Assert + Assert.Equal(2, count); // Only submitted and in progress + } + + [Fact] + public async Task GetUrgentMaintenanceRequestCountAsync_ReturnsCorrectCount() + { + // Arrange + var urgent1 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Urgent 1", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var urgent2 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Urgent 2", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var high = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "High Priority", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Other, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(urgent1); + await _service.CreateAsync(urgent2); + await _service.CreateAsync(high); + + // Act + var count = await _service.GetUrgentMaintenanceRequestCountAsync(); + + // Assert + Assert.Equal(2, count); + } + + [Fact] + public async Task GetMaintenanceRequestsByAssigneeAsync_ReturnsSortedByPriority() + { + // Arrange + var low = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Low Priority", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Other, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Low, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + AssignedTo = "John Smith", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var urgent = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Urgent Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + AssignedTo = "John Smith", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var high = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "High Priority", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + AssignedTo = "John Smith", + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(low); + await _service.CreateAsync(urgent); + await _service.CreateAsync(high); + + // Act + var results = await _service.GetMaintenanceRequestsByAssigneeAsync("John Smith"); + + // Assert + Assert.Equal(3, results.Count); + Assert.Equal("Urgent", results[0].Priority); // Urgent first + Assert.Equal("High", results[1].Priority); // High second + Assert.Equal("Low", results[2].Priority); // Low last + } + + [Fact] + public async Task CalculateAverageDaysToCompleteAsync_ReturnsCorrectAverage() + { + // Arrange + var completed1 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 1", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today.AddDays(-10), + CompletedOn = DateTime.Today.AddDays(-5), // 5 days + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var completed2 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 2", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today.AddDays(-7), + CompletedOn = DateTime.Today, // 7 days + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(completed1); + await _service.CreateAsync(completed2); + + // Act + var average = await _service.CalculateAverageDaysToCompleteAsync(); + + // Assert + Assert.Equal(6.0, average); // (5 + 7) / 2 = 6 + } + + [Fact] + public async Task GetMaintenanceCostsByPropertyAsync_ReturnsCorrectTotals() + { + // Arrange + var property2 = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrg.Id, + Address = "456 Other St", + City = "Test City", + State = "TS", + ZipCode = "12345", + PropertyType = "Condo", + Bedrooms = 2, + Bathrooms = 1, + SquareFeet = 1000, + IsAvailable = true, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property2); + await _context.SaveChangesAsync(); + + var request1 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 1", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today.AddDays(-10), + CompletedOn = DateTime.Today, + ActualCost = 150, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var request2 = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Request 2", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today.AddDays(-5), + CompletedOn = DateTime.Today, + ActualCost = 200, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + var request3 = new MaintenanceRequest + { + PropertyId = property2.Id, + Title = "Request 3", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Other, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Low, + Status = ApplicationConstants.MaintenanceRequestStatuses.Completed, + RequestedOn = DateTime.Today.AddDays(-3), + CompletedOn = DateTime.Today, + ActualCost = 75, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + + await _service.CreateAsync(request1); + await _service.CreateAsync(request2); + await _service.CreateAsync(request3); + + // Act + var costs = await _service.GetMaintenanceCostsByPropertyAsync(); + + // Assert + Assert.Equal(2, costs.Count); + Assert.Equal(350m, costs[_testProperty.Id]); // 150 + 200 + Assert.Equal(75m, costs[property2.Id]); + } + + [Fact] + public async Task DeleteAsync_RemovesCalendarEvent() + { + // Arrange + var request = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "Test Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(request); + + // Act + await _service.DeleteAsync(request.Id); + + // Assert + _mockCalendarEventService.Verify( + x => x.DeleteEventAsync(It.IsAny()), + Times.Once); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = _testUserId, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(otherOrg); + await _context.SaveChangesAsync(); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + PropertyType = "Condo", + Bedrooms = 2, + Bathrooms = 1, + SquareFeet = 900, + IsAvailable = true, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(otherProperty); + await _context.SaveChangesAsync(); + + var otherRequest = new MaintenanceRequest + { + PropertyId = otherProperty.Id, + Title = "Other Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = otherOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.MaintenanceRequests.Add(otherRequest); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherRequest.Id); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationRequests() + { + // Arrange + var myRequest = new MaintenanceRequest + { + PropertyId = _testProperty.Id, + Title = "My Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, + Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, + Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, + RequestedOn = DateTime.Today, + OrganizationId = _testOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(myRequest); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = _testUserId, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(otherOrg); + await _context.SaveChangesAsync(); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + PropertyType = "Condo", + Bedrooms = 2, + Bathrooms = 1, + SquareFeet = 900, + IsAvailable = true, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(otherProperty); + + var otherRequest = new MaintenanceRequest + { + PropertyId = otherProperty.Id, + Title = "Other Request", + Description = "Description", + RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical, + Priority = ApplicationConstants.MaintenanceRequestPriorities.High, + Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress, + RequestedOn = DateTime.Today, + OrganizationId = otherOrg.Id, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.MaintenanceRequests.Add(otherRequest); + await _context.SaveChangesAsync(); + + // Act + var results = await _service.GetAllAsync(); + + // Assert + Assert.Single(results); + Assert.Equal(_testOrg.Id, results[0].OrganizationId); + Assert.Equal("My Request", results[0].Title); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs new file mode 100644 index 0000000..90c7aba --- /dev/null +++ b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs @@ -0,0 +1,983 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using PaymentService = Aquiis.SimpleStart.Application.Services.PaymentService; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Comprehensive unit tests for PaymentService. + /// Tests CRUD operations, validation, business logic, and organization isolation. + /// + public class PaymentServiceTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly Mock> _mockLogger; + private readonly IOptions _mockSettings; + private readonly PaymentService _service; + private readonly Guid _testOrgId = Guid.NewGuid(); + private readonly string _testUserId = "test-user-123"; + private readonly Guid _testPropertyId = Guid.NewGuid(); + private readonly Guid _testTenantId = Guid.NewGuid(); + private readonly Guid _testLeaseId = Guid.NewGuid(); + private readonly Guid _testInvoiceId = Guid.NewGuid(); + + public PaymentServiceTests() + { + // Setup SQLite in-memory database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Mock AuthenticationStateProvider with claims + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Create test user + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + // Create test organization + var organization = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(organization); + + // Create test property + var property = new Property + { + Id = _testPropertyId, + OrganizationId = _testOrgId, + Address = "123 Test St", + City = "Test City", + State = "TS", + ZipCode = "12345", + IsAvailable = false, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(property); + + // Create test tenant + var tenant = new Tenant + { + Id = _testTenantId, + OrganizationId = _testOrgId, + FirstName = "Test", + LastName = "Tenant", + Email = "tenant@test.com", + IdentificationNumber = "SSN123456", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Tenants.Add(tenant); + + // Create test lease + var lease = new Lease + { + Id = _testLeaseId, + OrganizationId = _testOrgId, + PropertyId = _testPropertyId, + TenantId = _testTenantId, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 1500, + SecurityDeposit = 1500, + Status = "Active", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Leases.Add(lease); + + // Create test invoice + var invoice = new Invoice + { + Id = _testInvoiceId, + OrganizationId = _testOrgId, + LeaseId = _testLeaseId, + InvoiceNumber = "INV-TEST-001", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 1500, + Description = "Test Invoice", + Status = "Pending", + AmountPaid = 0, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Invoices.Add(invoice); + + _context.SaveChanges(); + + // Setup logger and settings + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + // Create service instance + _service = new PaymentService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_ValidPayment_CreatesSuccessfully() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _service.CreateAsync(payment); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal(1500, result.Amount); + Assert.Equal("Check", result.PaymentMethod); + } + + [Fact] + public async Task CreateAsync_MissingInvoiceId_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = Guid.Empty, // Missing + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + [Fact] + public async Task CreateAsync_ZeroAmount_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 0, // Invalid + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + [Fact] + public async Task CreateAsync_NegativeAmount_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = -100, // Invalid + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + [Fact] + public async Task CreateAsync_FuturePaymentDate_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today.AddDays(5), // Future date + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + [Fact] + public async Task CreateAsync_ExceedsInvoiceBalance_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 2000, // Exceeds invoice amount of 1500 + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + [Fact] + public async Task CreateAsync_InvalidPaymentMethod_ThrowsException() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "InvalidMethod", // Invalid + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(payment)); + } + + #endregion + + #region Retrieval Tests + + [Fact] + public async Task GetPaymentsByInvoiceIdAsync_ReturnsInvoicePayments() + { + // Arrange - Create payments + var payment1 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment1); + + var payment2 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today.AddDays(1), + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment2); + + // Act + var result = await _service.GetPaymentsByInvoiceIdAsync(_testInvoiceId); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, p => Assert.Equal(_testInvoiceId, p.InvoiceId)); + } + + [Fact] + public async Task GetPaymentsByMethodAsync_ReturnsMatchingPayments() + { + // Arrange + var check = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(check); + + var cash = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(cash); + + // Act + var checkResults = await _service.GetPaymentsByMethodAsync("Check"); + var cashResults = await _service.GetPaymentsByMethodAsync("Cash"); + + // Assert + Assert.Single(checkResults); + Assert.Single(cashResults); + Assert.Equal("Check", checkResults[0].PaymentMethod); + Assert.Equal("Cash", cashResults[0].PaymentMethod); + } + + [Fact] + public async Task GetPaymentsByDateRangeAsync_ReturnsPaymentsInRange() + { + // Arrange + var oldPayment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today.AddMonths(-2), + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(oldPayment); + + var recentPayment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(recentPayment); + + // Act + var startDate = DateTime.Today.AddDays(-7); + var endDate = DateTime.Today.AddDays(1); + var result = await _service.GetPaymentsByDateRangeAsync(startDate, endDate); + + // Assert + Assert.Single(result); + Assert.Equal(recentPayment.Amount, result[0].Amount); + } + + [Fact] + public async Task GetPaymentWithRelationsAsync_LoadsAllRelations() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(payment); + + // Act + var result = await _service.GetPaymentWithRelationsAsync(created.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Invoice); + Assert.NotNull(result.Invoice.Lease); + Assert.NotNull(result.Invoice.Lease.Property); + Assert.NotNull(result.Invoice.Lease.Tenant); + Assert.Equal(_testInvoiceId, result.Invoice.Id); + } + + #endregion + + #region Business Logic Tests + + [Fact] + public async Task CreateAsync_UpdatesInvoiceStatus_WhenFullyPaid() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, // Full amount + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + await _service.CreateAsync(payment); + + // Assert - Check invoice status updated + var invoice = await _context.Invoices.FindAsync(_testInvoiceId); + Assert.NotNull(invoice); + Assert.Equal("Paid", invoice.Status); + Assert.Equal(1500, invoice.AmountPaid); + Assert.NotNull(invoice.PaidOn); + } + + [Fact] + public async Task CreateAsync_PartialPayment_UpdatesInvoiceAmountPaid() + { + // Arrange + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, // Partial payment + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + await _service.CreateAsync(payment); + + // Assert - Check invoice updated but not marked as paid + var invoice = await _context.Invoices.FindAsync(_testInvoiceId); + Assert.NotNull(invoice); + Assert.Equal("Pending", invoice.Status); + Assert.Equal(750, invoice.AmountPaid); + } + + [Fact] + public async Task CreateAsync_MultiplePayments_UpdatesInvoiceCorrectly() + { + // Arrange - Create two partial payments + var payment1 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment1); + + var payment2 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, + PaidOn = DateTime.Today.AddDays(1), + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + + // Act + await _service.CreateAsync(payment2); + + // Assert - Invoice should be fully paid + var invoice = await _context.Invoices.FindAsync(_testInvoiceId); + Assert.NotNull(invoice); + Assert.Equal("Paid", invoice.Status); + Assert.Equal(1500, invoice.AmountPaid); + } + + [Fact] + public async Task DeleteAsync_UpdatesInvoiceStatus() + { + // Arrange - Create full payment + var payment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + var created = await _service.CreateAsync(payment); + + // Verify invoice is paid + var invoice = await _context.Invoices.FindAsync(_testInvoiceId); + Assert.Equal("Paid", invoice!.Status); + + // Act - Delete payment + await _service.DeleteAsync(created.Id); + + // Assert - Invoice should be back to pending + invoice = await _context.Invoices.FindAsync(_testInvoiceId); + Assert.NotNull(invoice); + Assert.Equal("Pending", invoice.Status); + Assert.Equal(0, invoice.AmountPaid); + } + + [Fact] + public async Task CalculateTotalPaymentsAsync_ReturnsCorrectTotal() + { + // Arrange - Create payments + var payment1 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment1); + + var payment2 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment2); + + // Act + var total = await _service.CalculateTotalPaymentsAsync(); + + // Assert + Assert.Equal(1250m, total); + } + + [Fact] + public async Task CalculateTotalPaymentsAsync_WithDateRange_ReturnsFilteredTotal() + { + // Arrange + var oldPayment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today.AddMonths(-2), + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(oldPayment); + + var recentPayment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(recentPayment); + + // Act + var startDate = DateTime.Today.AddDays(-7); + var endDate = DateTime.Today.AddDays(1); + var total = await _service.CalculateTotalPaymentsAsync(startDate, endDate); + + // Assert + Assert.Equal(750m, total); // Only recent payment + } + + [Fact] + public async Task GetPaymentSummaryByMethodAsync_ReturnsCorrectSummary() + { + // Arrange + var check1 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(check1); + + var check2 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 300, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(check2); + + var cash = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 700, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(cash); + + // Act + var summary = await _service.GetPaymentSummaryByMethodAsync(); + + // Assert + Assert.Equal(2, summary.Count); + Assert.Equal(800m, summary["Check"]); // 500 + 300 + Assert.Equal(700m, summary["Cash"]); + } + + [Fact] + public async Task GetTotalPaidForInvoiceAsync_ReturnsCorrectTotal() + { + // Arrange + var payment1 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment1); + + var payment2 = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 750, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(payment2); + + // Act + var total = await _service.GetTotalPaidForInvoiceAsync(_testInvoiceId); + + // Assert + Assert.Equal(1250m, total); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange - Create different organization and payment + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Tenant", + Email = "other@test.com", + IdentificationNumber = "ID999", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2000, + SecurityDeposit = 2000, + Status = "Active", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherLease); + + var otherInvoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + LeaseId = otherLease.Id, + InvoiceNumber = "INV-OTHER", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 2000, + Description = "Other Org Invoice", + Status = "Pending", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Invoices.AddAsync(otherInvoice); + + var otherOrgPayment = new Payment + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + InvoiceId = otherInvoice.Id, + Amount = 2000, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Payments.AddAsync(otherOrgPayment); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherOrgPayment.Id); + + // Assert + Assert.Null(result); // Should not access payment from different org + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationPayments() + { + // Arrange - Create payment in test org + var testOrgPayment = new Payment + { + OrganizationId = _testOrgId, + InvoiceId = _testInvoiceId, + Amount = 1500, + PaidOn = DateTime.Today, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _service.CreateAsync(testOrgPayment); + + // Create payment in different org + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "888 Other Ave", + City = "Other City", + State = "OT", + ZipCode = "88888", + IsAvailable = true, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(otherProperty); + + var otherTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Tenant", + Email = "other@test.com", + IdentificationNumber = "ID888", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherTenant); + + var otherLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + PropertyId = otherProperty.Id, + TenantId = otherTenant.Id, + StartDate = DateTime.Today, + EndDate = DateTime.Today.AddYears(1), + MonthlyRent = 2500, + SecurityDeposit = 2500, + Status = "Active", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(otherLease); + + var otherInvoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + LeaseId = otherLease.Id, + InvoiceNumber = "INV-OTHER-ORG", + InvoicedOn = DateTime.Today, + DueOn = DateTime.Today.AddDays(30), + Amount = 2500, + Description = "Other", + Status = "Pending", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Invoices.AddAsync(otherInvoice); + + var otherOrgPayment = new Payment + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + InvoiceId = otherInvoice.Id, + Amount = 2500, + PaidOn = DateTime.Today, + PaymentMethod = "Cash", + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Payments.AddAsync(otherOrgPayment); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(_testOrgId, result[0].OrganizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart.Tests/TenantServiceTests.cs b/Aquiis.SimpleStart.Tests/TenantServiceTests.cs new file mode 100644 index 0000000..1c9293b --- /dev/null +++ b/Aquiis.SimpleStart.Tests/TenantServiceTests.cs @@ -0,0 +1,865 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Aquiis.SimpleStart.Application.Services; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Components.Account; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Aquiis.SimpleStart.Tests +{ + /// + /// Comprehensive unit tests for TenantService. + /// Tests business logic, validation, search, and relationships. + /// + public class TenantServiceTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _context; + private readonly UserContextService _userContext; + private readonly Mock> _mockLogger; + private readonly IOptions _mockSettings; + private readonly TenantService _service; + private readonly Guid _testOrgId = Guid.NewGuid(); + private readonly string _testUserId = Guid.NewGuid().ToString(); + + public TenantServiceTests() + { + // Setup SQLite in-memory database + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new ApplicationDbContext(options); + _context.Database.EnsureCreated(); + + // Mock AuthenticationStateProvider with claims + var claims = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) }, + "TestAuth")); + var mockAuth = new Mock(); + mockAuth.Setup(a => a.GetAuthenticationStateAsync()) + .ReturnsAsync(new AuthenticationState(claims)); + + // Mock UserManager + var mockUserStore = new Mock>(); + var mockUserManager = new Mock>( + mockUserStore.Object, null, null, null, null, null, null, null, null); + + var appUser = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny())) + .ReturnsAsync(appUser); + + var serviceProvider = new Mock(); + _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object); + + // Create test user (required for Organization.OwnerId foreign key) + var user = new ApplicationUser + { + Id = _testUserId, + UserName = "testuser", + Email = "testuser@example.com", + ActiveOrganizationId = _testOrgId + }; + _context.Users.Add(user); + + // Create test organization + var organization = new Organization + { + Id = _testOrgId, + Name = "Test Organization", + OwnerId = _testUserId, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + _context.Organizations.Add(organization); + _context.SaveChanges(); + + // Setup logger and settings + _mockLogger = new Mock>(); + + _mockSettings = Options.Create(new ApplicationSettings + { + SoftDeleteEnabled = true + }); + + // Create service instance + _service = new TenantService( + _context, + _mockLogger.Object, + _userContext, + _mockSettings); + } + + public void Dispose() + { + _context.Dispose(); + _connection.Close(); + _connection.Dispose(); + } + + #region Validation Tests + + [Fact] + public async Task CreateAsync_ValidTenant_CreatesSuccessfully() + { + // Arrange + var tenant = new Tenant + { + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + IdentificationNumber = "SSN123456789", + PhoneNumber = "555-1234" + }; + + // Act + var result = await _service.CreateAsync(tenant); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.Id); + Assert.Equal(_testOrgId, result.OrganizationId); + Assert.Equal(_testUserId, result.CreatedBy); + Assert.Equal("John", result.FirstName); + Assert.Equal("Doe", result.LastName); + Assert.Equal("john.doe@example.com", result.Email); + Assert.Equal("SSN123456789", result.IdentificationNumber); + } + + [Fact] + public async Task CreateAsync_MissingEmail_ThrowsException() + { + // Arrange + var tenant = new Tenant + { + FirstName = "John", + LastName = "Doe", + Email = "", // Missing email + IdentificationNumber = "SSN123456789" + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(tenant)); + } + + [Fact] + public async Task CreateAsync_MissingIdentificationNumber_ThrowsException() + { + // Arrange + var tenant = new Tenant + { + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + IdentificationNumber = "" // Missing ID number + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(tenant)); + } + + [Fact] + public async Task CreateAsync_DuplicateEmail_ThrowsException() + { + // Arrange + var tenant1 = new Tenant + { + FirstName = "John", + LastName = "Doe", + Email = "duplicate@example.com", + IdentificationNumber = "SSN111111111" + }; + await _service.CreateAsync(tenant1); + + var tenant2 = new Tenant + { + FirstName = "Jane", + LastName = "Smith", + Email = "duplicate@example.com", // Duplicate email + IdentificationNumber = "SSN222222222" + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(tenant2)); + } + + [Fact] + public async Task CreateAsync_DuplicateIdentificationNumber_ThrowsException() + { + // Arrange + var tenant1 = new Tenant + { + FirstName = "John", + LastName = "Doe", + Email = "john@example.com", + IdentificationNumber = "SSN999999999" + }; + await _service.CreateAsync(tenant1); + + var tenant2 = new Tenant + { + FirstName = "Jane", + LastName = "Smith", + Email = "jane@example.com", + IdentificationNumber = "SSN999999999" // Duplicate ID number + }; + + // Act & Assert + await Assert.ThrowsAsync(() => _service.CreateAsync(tenant2)); + } + + #endregion + + #region Search Tests + + [Fact] + public async Task SearchTenantsAsync_ByFirstName_ReturnsTenants() + { + // Arrange + await _service.CreateAsync(new Tenant + { + FirstName = "Alice", + LastName = "Johnson", + Email = "alice@example.com", + IdentificationNumber = "SSN001" + }); + await _service.CreateAsync(new Tenant + { + FirstName = "Bob", + LastName = "Smith", + Email = "bob@example.com", + IdentificationNumber = "SSN002" + }); + + // Act + var result = await _service.SearchTenantsAsync("Alice"); + + // Assert + Assert.Single(result); + Assert.Equal("Alice", result[0].FirstName); + } + + [Fact] + public async Task SearchTenantsAsync_ByLastName_ReturnsTenants() + { + // Arrange + await _service.CreateAsync(new Tenant + { + FirstName = "John", + LastName = "Williams", + Email = "john.w@example.com", + IdentificationNumber = "SSN003" + }); + await _service.CreateAsync(new Tenant + { + FirstName = "Jane", + LastName = "Williams", + Email = "jane.w@example.com", + IdentificationNumber = "SSN004" + }); + + // Act + var result = await _service.SearchTenantsAsync("Williams"); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, t => Assert.Equal("Williams", t.LastName)); + } + + [Fact] + public async Task SearchTenantsAsync_ByEmail_ReturnsTenant() + { + // Arrange + await _service.CreateAsync(new Tenant + { + FirstName = "Charlie", + LastName = "Brown", + Email = "charlie.unique@example.com", + IdentificationNumber = "SSN005" + }); + + // Act + var result = await _service.SearchTenantsAsync("charlie.unique"); + + // Assert + Assert.Single(result); + Assert.Equal("charlie.unique@example.com", result[0].Email); + } + + [Fact] + public async Task SearchTenantsAsync_ByIdentificationNumber_ReturnsTenant() + { + // Arrange + await _service.CreateAsync(new Tenant + { + FirstName = "David", + LastName = "Miller", + Email = "david@example.com", + IdentificationNumber = "SSN777777777" + }); + + // Act + var result = await _service.SearchTenantsAsync("SSN777777777"); + + // Assert + Assert.Single(result); + Assert.Equal("SSN777777777", result[0].IdentificationNumber); + } + + [Fact] + public async Task SearchTenantsAsync_EmptySearchTerm_ReturnsFirst20() + { + // Arrange - Create 25 tenants + for (int i = 1; i <= 25; i++) + { + await _service.CreateAsync(new Tenant + { + FirstName = $"Tenant{i}", + LastName = $"Test{i}", + Email = $"tenant{i}@example.com", + IdentificationNumber = $"SSN{i:D9}" + }); + } + + // Act + var result = await _service.SearchTenantsAsync(""); + + // Assert + Assert.Equal(20, result.Count); // Should limit to 20 + } + + [Fact] + public async Task SearchTenantsAsync_NoMatch_ReturnsEmpty() + { + // Arrange + await _service.CreateAsync(new Tenant + { + FirstName = "Eve", + LastName = "Anderson", + Email = "eve@example.com", + IdentificationNumber = "SSN006" + }); + + // Act + var result = await _service.SearchTenantsAsync("NonExistentName"); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region Lookup Tests + + [Fact] + public async Task GetTenantByEmailAsync_ExistingEmail_ReturnsTenant() + { + // Arrange + var tenant = await _service.CreateAsync(new Tenant + { + FirstName = "Frank", + LastName = "Garcia", + Email = "frank.garcia@example.com", + IdentificationNumber = "SSN007" + }); + + // Act + var result = await _service.GetTenantByEmailAsync("frank.garcia@example.com"); + + // Assert + Assert.NotNull(result); + Assert.Equal(tenant.Id, result.Id); + Assert.Equal("frank.garcia@example.com", result.Email); + } + + [Fact] + public async Task GetTenantByEmailAsync_NonExistentEmail_ReturnsNull() + { + // Act + var result = await _service.GetTenantByEmailAsync("nonexistent@example.com"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetTenantByIdentificationNumberAsync_ExistingNumber_ReturnsTenant() + { + // Arrange + var tenant = await _service.CreateAsync(new Tenant + { + FirstName = "Grace", + LastName = "Martinez", + Email = "grace@example.com", + IdentificationNumber = "SSN888888888" + }); + + // Act + var result = await _service.GetTenantByIdentificationNumberAsync("SSN888888888"); + + // Assert + Assert.NotNull(result); + Assert.Equal(tenant.Id, result.Id); + Assert.Equal("SSN888888888", result.IdentificationNumber); + } + + [Fact] + public async Task GetTenantByIdentificationNumberAsync_NonExistentNumber_ReturnsNull() + { + // Act + var result = await _service.GetTenantByIdentificationNumberAsync("NONEXISTENT"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Active Tenant Tests + + [Fact] + public async Task GetActiveTenantsAsync_ReturnsOnlyActiveTenants() + { + // Arrange + var activeTenant = await _service.CreateAsync(new Tenant + { + FirstName = "Active", + LastName = "Tenant", + Email = "active@example.com", + IdentificationNumber = "SSN010", + IsActive = true + }); + + var inactiveTenant = await _service.CreateAsync(new Tenant + { + FirstName = "Inactive", + LastName = "Tenant", + Email = "inactive@example.com", + IdentificationNumber = "SSN011", + IsActive = false + }); + + // Act + var result = await _service.GetActiveTenantsAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(activeTenant.Id, result[0].Id); + Assert.True(result[0].IsActive); + } + + #endregion + + #region Tenant with Active Leases Tests + + [Fact] + public async Task GetTenantsWithActiveLeasesAsync_ReturnsOnlyTenantsWithActiveLeases() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "123 Main St", + City = "Test City", + State = "TS", + ZipCode = "12345", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(property); + + var tenantWithActiveLease = await _service.CreateAsync(new Tenant + { + FirstName = "With", + LastName = "ActiveLease", + Email = "withlease@example.com", + IdentificationNumber = "SSN012" + }); + + var tenantWithoutLease = await _service.CreateAsync(new Tenant + { + FirstName = "Without", + LastName = "Lease", + Email = "withoutlease@example.com", + IdentificationNumber = "SSN013" + }); + + var activeLease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + TenantId = tenantWithActiveLease.Id, + Status = ApplicationConstants.LeaseStatuses.Active, + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddMonths(12), + MonthlyRent = 1000, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(activeLease); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetTenantsWithActiveLeasesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(tenantWithActiveLease.Id, result[0].Id); + } + + #endregion + + #region Relationship Tests + + [Fact] + public async Task GetTenantWithRelationsAsync_LoadsLeases() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "456 Oak Ave", + City = "Test City", + State = "TS", + ZipCode = "12345", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(property); + + var tenant = await _service.CreateAsync(new Tenant + { + FirstName = "Henry", + LastName = "Wilson", + Email = "henry@example.com", + IdentificationNumber = "SSN014" + }); + + var lease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + TenantId = tenant.Id, + Status = ApplicationConstants.LeaseStatuses.Active, + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddMonths(6), + MonthlyRent = 1200, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(lease); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetTenantWithRelationsAsync(tenant.Id); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Leases); + Assert.Single(result.Leases); + Assert.Equal(lease.Id, result.Leases.First().Id); + } + + [Fact] + public async Task GetTenantsByPropertyIdAsync_ReturnsTenantsForProperty() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "789 Pine Rd", + City = "Test City", + State = "TS", + ZipCode = "12345", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(property); + + var tenant1 = await _service.CreateAsync(new Tenant + { + FirstName = "Tenant", + LastName = "One", + Email = "tenant1@example.com", + IdentificationNumber = "SSN015" + }); + + var tenant2 = await _service.CreateAsync(new Tenant + { + FirstName = "Tenant", + LastName = "Two", + Email = "tenant2@example.com", + IdentificationNumber = "SSN016" + }); + + var lease1 = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + TenantId = tenant1.Id, + Status = ApplicationConstants.LeaseStatuses.Active, + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddMonths(12), + MonthlyRent = 1000, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(lease1); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetTenantsByPropertyIdAsync(property.Id); + + // Assert + Assert.Single(result); + Assert.Equal(tenant1.Id, result[0].Id); + } + + #endregion + + #region Balance Calculation Tests + + [Fact] + public async Task CalculateTenantBalanceAsync_CalculatesCorrectBalance() + { + // Arrange + var property = new Property + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + Address = "111 Balance St", + City = "Test City", + State = "TS", + ZipCode = "12345", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Properties.AddAsync(property); + + var tenant = await _service.CreateAsync(new Tenant + { + FirstName = "Balance", + LastName = "Test", + Email = "balance@example.com", + IdentificationNumber = "SSN017" + }); + + var lease = new Lease + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + PropertyId = property.Id, + TenantId = tenant.Id, + Status = ApplicationConstants.LeaseStatuses.Active, + StartDate = DateTime.UtcNow, + EndDate = DateTime.UtcNow.AddMonths(12), + MonthlyRent = 1000, + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Leases.AddAsync(lease); + + var invoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + LeaseId = lease.Id, + Amount = 1000, + AmountPaid = 0, + DueOn = DateTime.UtcNow, + Status = "Outstanding", + InvoiceNumber = "INV-001", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Invoices.AddAsync(invoice); + + var payment = new Payment + { + Id = Guid.NewGuid(), + OrganizationId = _testOrgId, + InvoiceId = invoice.Id, + Amount = 600, + PaidOn = DateTime.UtcNow, + PaymentMethod = "Check", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Payments.AddAsync(payment); + await _context.SaveChangesAsync(); + + // Act + var balance = await _service.CalculateTenantBalanceAsync(tenant.Id); + + // Assert + Assert.Equal(400, balance); // 1000 - 600 = 400 + } + + [Fact] + public async Task CalculateTenantBalanceAsync_NoInvoices_ReturnsZero() + { + // Arrange + var tenant = await _service.CreateAsync(new Tenant + { + FirstName = "Zero", + LastName = "Balance", + Email = "zero@example.com", + IdentificationNumber = "SSN018" + }); + + // Act + var balance = await _service.CalculateTenantBalanceAsync(tenant.Id); + + // Assert + Assert.Equal(0, balance); + } + + [Fact] + public async Task CalculateTenantBalanceAsync_NonExistentTenant_ThrowsException() + { + // Arrange + var nonExistentTenantId = Guid.NewGuid(); + + // Act & Assert + await Assert.ThrowsAsync(() => + _service.CalculateTenantBalanceAsync(nonExistentTenantId)); + } + + #endregion + + #region Organization Isolation Tests + + [Fact] + public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() + { + // Arrange - Create different organization with different owner + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + var otherOrgTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Org", + Email = "other@example.com", + IdentificationNumber = "SSN999", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherOrgTenant); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetByIdAsync(otherOrgTenant.Id); + + // Assert + Assert.Null(result); // Should not access tenant from different org + } + + [Fact] + public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationTenants() + { + // Arrange - Create different organization with different owner + var otherUserId = "other-user-456"; + var otherUser = new ApplicationUser + { + Id = otherUserId, + UserName = "otheruser", + Email = "otheruser@example.com" + }; + _context.Users.Add(otherUser); + await _context.SaveChangesAsync(); + + var otherOrg = new Organization + { + Id = Guid.NewGuid(), + Name = "Other Organization", + OwnerId = otherUserId, + CreatedBy = otherUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Organizations.AddAsync(otherOrg); + + // Create tenant in test org + await _service.CreateAsync(new Tenant + { + FirstName = "Test", + LastName = "Org", + Email = "testorg@example.com", + IdentificationNumber = "SSN020" + }); + + // Create tenant in other org + var otherOrgTenant = new Tenant + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + FirstName = "Other", + LastName = "Org", + Email = "otherorg@example.com", + IdentificationNumber = "SSN021", + CreatedBy = _testUserId, + CreatedOn = DateTime.UtcNow + }; + await _context.Tenants.AddAsync(otherOrgTenant); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetAllAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(_testOrgId, result[0].OrganizationId); + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs b/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs index b5a92e9..432fd23 100644 --- a/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs +++ b/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Aquiis.SimpleStart.Infrastructure.Data; using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; using Aquiis.SimpleStart.Shared.Services; namespace Aquiis.SimpleStart.Application.Services @@ -8,7 +9,7 @@ namespace Aquiis.SimpleStart.Application.Services /// /// Service for managing calendar events and synchronizing with schedulable entities /// - public class CalendarEventService + public class CalendarEventService : ICalendarEventService { private readonly ApplicationDbContext _context; private readonly CalendarSettingsService _settingsService; diff --git a/Aquiis.SimpleStart/Application/Services/DocumentService.cs b/Aquiis.SimpleStart/Application/Services/DocumentService.cs new file mode 100644 index 0000000..36671c3 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/DocumentService.cs @@ -0,0 +1,419 @@ +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Document entities. + /// Inherits common CRUD operations from BaseService and adds document-specific business logic. + /// + public class DocumentService : BaseService + { + public DocumentService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with Document-Specific Logic + + /// + /// Validates a document entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(Document entity) + { + var errors = new List(); + + // Required field validation + if (string.IsNullOrWhiteSpace(entity.FileName)) + { + errors.Add("FileName is required"); + } + + if (string.IsNullOrWhiteSpace(entity.FileExtension)) + { + errors.Add("FileExtension is required"); + } + + if (string.IsNullOrWhiteSpace(entity.DocumentType)) + { + errors.Add("DocumentType is required"); + } + + if (entity.FileData == null || entity.FileData.Length == 0) + { + errors.Add("FileData is required"); + } + + // Business rule: At least one foreign key must be set + if (!entity.PropertyId.HasValue + && !entity.TenantId.HasValue + && !entity.LeaseId.HasValue + && !entity.InvoiceId.HasValue + && !entity.PaymentId.HasValue) + { + errors.Add("Document must be associated with at least one entity (Property, Tenant, Lease, Invoice, or Payment)"); + } + + // Validate file size (e.g., max 10MB) + const long maxFileSizeBytes = 10 * 1024 * 1024; // 10MB + if (entity.FileSize > maxFileSizeBytes) + { + errors.Add($"File size exceeds maximum allowed size of {maxFileSizeBytes / (1024 * 1024)}MB"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a document with all related entities. + /// + public async Task GetDocumentWithRelationsAsync(Guid documentId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var document = await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .ThenInclude(l => l!.Property) + .Include(d => d.Lease) + .ThenInclude(l => l!.Tenant) + .Include(d => d.Invoice) + .Include(d => d.Payment) + .Where(d => d.Id == documentId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .FirstOrDefaultAsync(); + + return document; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentWithRelations"); + throw; + } + } + + /// + /// Gets all documents with related entities. + /// + public async Task> GetDocumentsWithRelationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .ThenInclude(l => l!.Property) + .Include(d => d.Lease) + .ThenInclude(l => l!.Tenant) + .Include(d => d.Invoice) + .Include(d => d.Payment) + .Where(d => !d.IsDeleted && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets all documents for a specific property. + /// + public async Task> GetDocumentsByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => d.PropertyId == propertyId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByPropertyId"); + throw; + } + } + + /// + /// Gets all documents for a specific tenant. + /// + public async Task> GetDocumentsByTenantIdAsync(Guid tenantId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => d.TenantId == tenantId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByTenantId"); + throw; + } + } + + /// + /// Gets all documents for a specific lease. + /// + public async Task> GetDocumentsByLeaseIdAsync(Guid leaseId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Lease) + .ThenInclude(l => l!.Property) + .Include(d => d.Lease) + .ThenInclude(l => l!.Tenant) + .Where(d => d.LeaseId == leaseId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByLeaseId"); + throw; + } + } + + /// + /// Gets all documents for a specific invoice. + /// + public async Task> GetDocumentsByInvoiceIdAsync(Guid invoiceId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Invoice) + .Where(d => d.InvoiceId == invoiceId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByInvoiceId"); + throw; + } + } + + /// + /// Gets all documents for a specific payment. + /// + public async Task> GetDocumentsByPaymentIdAsync(Guid paymentId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Payment) + .Where(d => d.PaymentId == paymentId + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByPaymentId"); + throw; + } + } + + /// + /// Gets documents by document type (e.g., "Lease Agreement", "Invoice", "Receipt"). + /// + public async Task> GetDocumentsByTypeAsync(string documentType) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => d.DocumentType == documentType + && !d.IsDeleted + && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByType"); + throw; + } + } + + /// + /// Searches documents by filename. + /// + public async Task> SearchDocumentsByFilenameAsync(string searchTerm, int maxResults = 20) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + if (string.IsNullOrWhiteSpace(searchTerm)) + { + // Return recent documents if no search term + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => !d.IsDeleted && d.OrganizationId == organizationId) + .OrderByDescending(d => d.CreatedOn) + .Take(maxResults) + .ToListAsync(); + } + + var searchLower = searchTerm.ToLower(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => !d.IsDeleted + && d.OrganizationId == organizationId + && (d.FileName.ToLower().Contains(searchLower) + || d.Description.ToLower().Contains(searchLower))) + .OrderByDescending(d => d.CreatedOn) + .Take(maxResults) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "SearchDocumentsByFilename"); + throw; + } + } + + /// + /// Calculates total storage used by all documents in the organization (in bytes). + /// + public async Task CalculateTotalStorageUsedAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Where(d => !d.IsDeleted && d.OrganizationId == organizationId) + .SumAsync(d => d.FileSize); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateTotalStorageUsed"); + throw; + } + } + + /// + /// Gets documents uploaded within a specific date range. + /// + public async Task> GetDocumentsByDateRangeAsync(DateTime startDate, DateTime endDate) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Include(d => d.Property) + .Include(d => d.Tenant) + .Include(d => d.Lease) + .Where(d => !d.IsDeleted + && d.OrganizationId == organizationId + && d.CreatedOn >= startDate + && d.CreatedOn <= endDate) + .OrderByDescending(d => d.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentsByDateRange"); + throw; + } + } + + /// + /// Gets document count by document type for reporting. + /// + public async Task> GetDocumentCountByTypeAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Documents + .Where(d => !d.IsDeleted && d.OrganizationId == organizationId) + .GroupBy(d => d.DocumentType) + .Select(g => new { Type = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Type, x => x.Count); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetDocumentCountByType"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/InvoiceService.cs b/Aquiis.SimpleStart/Application/Services/InvoiceService.cs new file mode 100644 index 0000000..19811b6 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/InvoiceService.cs @@ -0,0 +1,465 @@ +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Invoice entities. + /// Inherits common CRUD operations from BaseService and adds invoice-specific business logic. + /// + public class InvoiceService : BaseService + { + public InvoiceService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + /// + /// Validates an invoice before create/update operations. + /// + protected override async Task ValidateEntityAsync(Invoice entity) + { + var errors = new List(); + + // Required fields + if (entity.LeaseId == Guid.Empty) + { + errors.Add("Lease ID is required."); + } + + if (string.IsNullOrWhiteSpace(entity.InvoiceNumber)) + { + errors.Add("Invoice number is required."); + } + + if (string.IsNullOrWhiteSpace(entity.Description)) + { + errors.Add("Description is required."); + } + + if (entity.Amount <= 0) + { + errors.Add("Amount must be greater than zero."); + } + + if (entity.DueOn < entity.InvoicedOn) + { + errors.Add("Due date cannot be before invoice date."); + } + + // Validate lease exists and belongs to organization + if (entity.LeaseId != Guid.Empty) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var lease = await _context.Leases + .Include(l => l.Property) + .FirstOrDefaultAsync(l => l.Id == entity.LeaseId && !l.IsDeleted); + + if (lease == null) + { + errors.Add($"Lease with ID {entity.LeaseId} does not exist."); + } + else if (lease.Property.OrganizationId != organizationId) + { + errors.Add("Lease does not belong to the current organization."); + } + } + + // Check for duplicate invoice number in same organization + if (!string.IsNullOrWhiteSpace(entity.InvoiceNumber)) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var duplicate = await _context.Invoices + .AnyAsync(i => i.InvoiceNumber == entity.InvoiceNumber + && i.OrganizationId == organizationId + && i.Id != entity.Id + && !i.IsDeleted); + + if (duplicate) + { + errors.Add($"Invoice number '{entity.InvoiceNumber}' already exists."); + } + } + + // Validate status + var validStatuses = new[] { "Pending", "Paid", "Overdue", "Cancelled" }; + if (!string.IsNullOrWhiteSpace(entity.Status) && !validStatuses.Contains(entity.Status)) + { + errors.Add($"Status must be one of: {string.Join(", ", validStatuses)}"); + } + + // Validate amount paid doesn't exceed amount + if (entity.AmountPaid > entity.Amount + (entity.LateFeeAmount ?? 0)) + { + errors.Add("Amount paid cannot exceed invoice amount plus late fees."); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join(" ", errors)); + } + } + + /// + /// Gets all invoices for a specific lease. + /// + public async Task> GetInvoicesByLeaseIdAsync(Guid leaseId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Where(i => i.LeaseId == leaseId + && !i.IsDeleted + && i.OrganizationId == organizationId) + .OrderByDescending(i => i.DueOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetInvoicesByLeaseId"); + throw; + } + } + + /// + /// Gets all invoices with a specific status. + /// + public async Task> GetInvoicesByStatusAsync(string status) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Where(i => i.Status == status + && !i.IsDeleted + && i.OrganizationId == organizationId) + .OrderByDescending(i => i.DueOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetInvoicesByStatus"); + throw; + } + } + + /// + /// Gets all overdue invoices (due date passed and not paid). + /// + public async Task> GetOverdueInvoicesAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Where(i => i.Status != "Paid" + && i.Status != "Cancelled" + && i.DueOn < today + && !i.IsDeleted + && i.OrganizationId == organizationId) + .OrderBy(i => i.DueOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetOverdueInvoices"); + throw; + } + } + + /// + /// Gets invoices due within the specified number of days. + /// + public async Task> GetInvoicesDueSoonAsync(int daysThreshold = 7) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + var thresholdDate = today.AddDays(daysThreshold); + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Where(i => i.Status == "Pending" + && i.DueOn >= today + && i.DueOn <= thresholdDate + && !i.IsDeleted + && i.OrganizationId == organizationId) + .OrderBy(i => i.DueOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetInvoicesDueSoon"); + throw; + } + } + + /// + /// Gets an invoice with all related entities loaded. + /// + public async Task GetInvoiceWithRelationsAsync(Guid invoiceId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Include(i => i.Document) + .FirstOrDefaultAsync(i => i.Id == invoiceId + && !i.IsDeleted + && i.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetInvoiceWithRelations"); + throw; + } + } + + /// + /// Generates a unique invoice number for the organization. + /// Format: INV-YYYYMM-00001 + /// + public async Task GenerateInvoiceNumberAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var invoiceCount = await _context.Invoices + .Where(i => i.OrganizationId == organizationId) + .CountAsync(); + + var nextNumber = invoiceCount + 1; + return $"INV-{DateTime.Now:yyyyMM}-{nextNumber:D5}"; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GenerateInvoiceNumber"); + throw; + } + } + + /// + /// Applies a late fee to an overdue invoice. + /// + public async Task ApplyLateFeeAsync(Guid invoiceId, decimal lateFeeAmount) + { + try + { + var invoice = await GetByIdAsync(invoiceId); + if (invoice == null) + { + throw new InvalidOperationException($"Invoice {invoiceId} not found."); + } + + if (invoice.Status == "Paid" || invoice.Status == "Cancelled") + { + throw new InvalidOperationException("Cannot apply late fee to paid or cancelled invoice."); + } + + if (invoice.LateFeeApplied == true) + { + throw new InvalidOperationException("Late fee has already been applied to this invoice."); + } + + if (lateFeeAmount <= 0) + { + throw new ArgumentException("Late fee amount must be greater than zero."); + } + + invoice.LateFeeAmount = lateFeeAmount; + invoice.LateFeeApplied = true; + invoice.LateFeeAppliedOn = DateTime.UtcNow; + + // Update status to overdue if not already + if (invoice.Status == "Pending") + { + invoice.Status = "Overdue"; + } + + await UpdateAsync(invoice); + + return invoice; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "ApplyLateFee"); + throw; + } + } + + /// + /// Marks a reminder as sent for an invoice. + /// + public async Task MarkReminderSentAsync(Guid invoiceId) + { + try + { + var invoice = await GetByIdAsync(invoiceId); + if (invoice == null) + { + throw new InvalidOperationException($"Invoice {invoiceId} not found."); + } + + invoice.ReminderSent = true; + invoice.ReminderSentOn = DateTime.UtcNow; + + await UpdateAsync(invoice); + + return invoice; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "MarkReminderSent"); + throw; + } + } + + /// + /// Updates the invoice status based on payments received. + /// + public async Task UpdateInvoiceStatusAsync(Guid invoiceId) + { + try + { + var invoice = await GetInvoiceWithRelationsAsync(invoiceId); + if (invoice == null) + { + throw new InvalidOperationException($"Invoice {invoiceId} not found."); + } + + // Calculate total amount due (including late fees) + var totalDue = invoice.Amount + (invoice.LateFeeAmount ?? 0); + var totalPaid = invoice.Payments.Where(p => !p.IsDeleted).Sum(p => p.Amount); + + invoice.AmountPaid = totalPaid; + + // Update status + if (totalPaid >= totalDue) + { + invoice.Status = "Paid"; + invoice.PaidOn = invoice.Payments + .Where(p => !p.IsDeleted) + .OrderByDescending(p => p.PaidOn) + .FirstOrDefault()?.PaidOn ?? DateTime.UtcNow; + } + else if (invoice.Status == "Cancelled") + { + // Don't change cancelled status + } + else if (invoice.DueOn < DateTime.Today) + { + invoice.Status = "Overdue"; + } + else + { + invoice.Status = "Pending"; + } + + await UpdateAsync(invoice); + + return invoice; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateInvoiceStatus"); + throw; + } + } + + /// + /// Calculates the total outstanding balance across all unpaid invoices. + /// + public async Task CalculateTotalOutstandingAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var total = await _context.Invoices + .Where(i => i.Status != "Paid" + && i.Status != "Cancelled" + && !i.IsDeleted + && i.OrganizationId == organizationId) + .SumAsync(i => (i.Amount + (i.LateFeeAmount ?? 0)) - i.AmountPaid); + + return total; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateTotalOutstanding"); + throw; + } + } + + /// + /// Gets invoices within a specific date range. + /// + public async Task> GetInvoicesByDateRangeAsync(DateTime startDate, DateTime endDate) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(i => i.Payments) + .Where(i => i.InvoicedOn >= startDate + && i.InvoicedOn <= endDate + && !i.IsDeleted + && i.OrganizationId == organizationId) + .OrderByDescending(i => i.InvoicedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetInvoicesByDateRange"); + throw; + } + } + } +} diff --git a/Aquiis.SimpleStart/Application/Services/LeaseService.cs b/Aquiis.SimpleStart/Application/Services/LeaseService.cs new file mode 100644 index 0000000..639a8ca --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/LeaseService.cs @@ -0,0 +1,492 @@ +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Lease entities. + /// Inherits common CRUD operations from BaseService and adds lease-specific business logic. + /// + public class LeaseService : BaseService + { + public LeaseService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with Lease-Specific Logic + + /// + /// Validates a lease entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(Lease entity) + { + var errors = new List(); + + // Required field validation + if (entity.PropertyId == Guid.Empty) + { + errors.Add("PropertyId is required"); + } + + if (entity.TenantId == Guid.Empty) + { + errors.Add("TenantId is required"); + } + + if (entity.StartDate == default) + { + errors.Add("StartDate is required"); + } + + if (entity.EndDate == default) + { + errors.Add("EndDate is required"); + } + + if (entity.MonthlyRent <= 0) + { + errors.Add("MonthlyRent must be greater than 0"); + } + + // Business rule validation + if (entity.EndDate <= entity.StartDate) + { + errors.Add("EndDate must be after StartDate"); + } + + // Check for overlapping leases on the same property + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var overlappingLease = await _context.Leases + .Include(l => l.Property) + .Where(l => l.PropertyId == entity.PropertyId + && l.Id != entity.Id + && !l.IsDeleted + && l.Property.OrganizationId == organizationId + && (l.Status == ApplicationConstants.LeaseStatuses.Active + || l.Status == ApplicationConstants.LeaseStatuses.Pending)) + .Where(l => + // New lease starts during existing lease + (entity.StartDate >= l.StartDate && entity.StartDate <= l.EndDate) || + // New lease ends during existing lease + (entity.EndDate >= l.StartDate && entity.EndDate <= l.EndDate) || + // New lease completely encompasses existing lease + (entity.StartDate <= l.StartDate && entity.EndDate >= l.EndDate)) + .FirstOrDefaultAsync(); + + if (overlappingLease != null) + { + errors.Add($"A lease already exists for this property during the specified date range (Lease ID: {overlappingLease.Id})"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + /// + /// Creates a new lease and updates the property availability status. + /// + public override async Task CreateAsync(Lease entity) + { + var lease = await base.CreateAsync(entity); + + // If lease is active, mark property as unavailable + if (entity.Status == ApplicationConstants.LeaseStatuses.Active) + { + var property = await _context.Properties.FindAsync(entity.PropertyId); + if (property != null) + { + property.IsAvailable = false; + property.LastModifiedOn = DateTime.UtcNow; + property.LastModifiedBy = await _userContext.GetUserIdAsync(); + _context.Properties.Update(property); + await _context.SaveChangesAsync(); + } + } + + return lease; + } + + /// + /// Deletes (soft deletes) a lease and updates property availability if needed. + /// + public override async Task DeleteAsync(Guid id) + { + var lease = await GetByIdAsync(id); + if (lease == null) return false; + + var result = await base.DeleteAsync(id); + + // If lease was active, check if property should be marked available + if (result && lease.Status == ApplicationConstants.LeaseStatuses.Active) + { + var property = await _context.Properties.FindAsync(lease.PropertyId); + if (property != null) + { + // Check if there are any other active/pending leases for this property + var hasOtherActiveLeases = await _context.Leases + .AnyAsync(l => l.PropertyId == lease.PropertyId + && l.Id != lease.Id + && !l.IsDeleted + && (l.Status == ApplicationConstants.LeaseStatuses.Active + || l.Status == ApplicationConstants.LeaseStatuses.Pending)); + + if (!hasOtherActiveLeases) + { + property.IsAvailable = true; + property.LastModifiedOn = DateTime.UtcNow; + property.LastModifiedBy = await _userContext.GetUserIdAsync(); + _context.Properties.Update(property); + await _context.SaveChangesAsync(); + } + } + } + + return result; + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a lease with all related entities (Property, Tenant, Documents, Invoices). + /// + public async Task GetLeaseWithRelationsAsync(Guid leaseId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var lease = await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Include(l => l.Document) + .Include(l => l.Documents) + .Include(l => l.Invoices) + .Where(l => l.Id == leaseId + && !l.IsDeleted + && l.Property.OrganizationId == organizationId) + .FirstOrDefaultAsync(); + + return lease; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseWithRelations"); + throw; + } + } + + /// + /// Gets all leases with Property and Tenant relations. + /// + public async Task> GetLeasesWithRelationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => !l.IsDeleted && l.Property.OrganizationId == organizationId) + .OrderByDescending(l => l.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeasesWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets all leases for a specific property. + /// + public async Task> GetLeasesByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => l.PropertyId == propertyId + && !l.IsDeleted + && l.Property.OrganizationId == organizationId) + .OrderByDescending(l => l.StartDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeasesByPropertyId"); + throw; + } + } + + /// + /// Gets all leases for a specific tenant. + /// + public async Task> GetLeasesByTenantIdAsync(Guid tenantId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => l.TenantId == tenantId + && !l.IsDeleted + && l.Property.OrganizationId == organizationId) + .OrderByDescending(l => l.StartDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeasesByTenantId"); + throw; + } + } + + /// + /// Gets all active leases (current leases within their term). + /// + public async Task> GetActiveLeasesAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => !l.IsDeleted + && l.Property.OrganizationId == organizationId + && l.Status == ApplicationConstants.LeaseStatuses.Active + && l.StartDate <= today + && l.EndDate >= today) + .OrderBy(l => l.Property.Address) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetActiveLeases"); + throw; + } + } + + /// + /// Gets leases that are expiring within the specified number of days. + /// + public async Task> GetLeasesExpiringSoonAsync(int daysThreshold = 90) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + var expirationDate = today.AddDays(daysThreshold); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => !l.IsDeleted + && l.Property.OrganizationId == organizationId + && l.Status == ApplicationConstants.LeaseStatuses.Active + && l.EndDate >= today + && l.EndDate <= expirationDate) + .OrderBy(l => l.EndDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeasesExpiringSoon"); + throw; + } + } + + /// + /// Gets leases by status. + /// + public async Task> GetLeasesByStatusAsync(string status) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => !l.IsDeleted + && l.Property.OrganizationId == organizationId + && l.Status == status) + .OrderByDescending(l => l.StartDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeasesByStatus"); + throw; + } + } + + /// + /// Gets current and upcoming leases for a property (Active or Pending status). + /// + public async Task> GetCurrentAndUpcomingLeasesByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => l.PropertyId == propertyId + && !l.IsDeleted + && l.Property.OrganizationId == organizationId + && (l.Status == ApplicationConstants.LeaseStatuses.Active + || l.Status == ApplicationConstants.LeaseStatuses.Pending)) + .OrderBy(l => l.StartDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetCurrentAndUpcomingLeasesByPropertyId"); + throw; + } + } + + /// + /// Gets active leases for a specific property. + /// + public async Task> GetActiveLeasesByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + + return await _context.Leases + .Include(l => l.Property) + .Include(l => l.Tenant) + .Where(l => l.PropertyId == propertyId + && !l.IsDeleted + && l.Property.OrganizationId == organizationId + && l.Status == ApplicationConstants.LeaseStatuses.Active + && l.StartDate <= today + && l.EndDate >= today) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetActiveLeasesByPropertyId"); + throw; + } + } + + /// + /// Calculates the total rent for a lease over its entire term. + /// + public async Task CalculateTotalLeaseValueAsync(Guid leaseId) + { + try + { + var lease = await GetByIdAsync(leaseId); + if (lease == null) + { + throw new InvalidOperationException($"Lease not found: {leaseId}"); + } + + var months = ((lease.EndDate.Year - lease.StartDate.Year) * 12) + + lease.EndDate.Month - lease.StartDate.Month; + + // Add 1 to include both start and end months + return lease.MonthlyRent * (months + 1); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateTotalLeaseValue"); + throw; + } + } + + /// + /// Updates the status of a lease. + /// + public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus) + { + try + { + var lease = await GetByIdAsync(leaseId); + if (lease == null) + { + throw new InvalidOperationException($"Lease not found: {leaseId}"); + } + + lease.Status = newStatus; + + // Update property availability based on status + var property = await _context.Properties.FindAsync(lease.PropertyId); + if (property != null) + { + if (newStatus == ApplicationConstants.LeaseStatuses.Active) + { + property.IsAvailable = false; + } + else if (newStatus == ApplicationConstants.LeaseStatuses.Terminated + || newStatus == ApplicationConstants.LeaseStatuses.Expired) + { + // Only mark available if no other active leases exist + var hasOtherActiveLeases = await _context.Leases + .AnyAsync(l => l.PropertyId == lease.PropertyId + && l.Id != lease.Id + && !l.IsDeleted + && (l.Status == ApplicationConstants.LeaseStatuses.Active + || l.Status == ApplicationConstants.LeaseStatuses.Pending)); + + if (!hasOtherActiveLeases) + { + property.IsAvailable = true; + } + } + + property.LastModifiedOn = DateTime.UtcNow; + property.LastModifiedBy = await _userContext.GetUserIdAsync(); + _context.Properties.Update(property); + } + + return await UpdateAsync(lease); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateLeaseStatus"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs b/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs new file mode 100644 index 0000000..f3351ec --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs @@ -0,0 +1,492 @@ +using Aquiis.SimpleStart.Application.Services.Workflows; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing maintenance requests with business logic for status updates, + /// assignment tracking, and overdue detection. + /// + public class MaintenanceService : BaseService + { + private readonly ICalendarEventService _calendarEventService; + + public MaintenanceService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings, + ICalendarEventService calendarEventService) + : base(context, logger, userContext, settings) + { + _calendarEventService = calendarEventService; + } + + /// + /// Validates maintenance request business rules. + /// + protected override async Task ValidateEntityAsync(MaintenanceRequest entity) + { + var errors = new List(); + + // Required fields + if (entity.PropertyId == Guid.Empty) + { + errors.Add("Property is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Title)) + { + errors.Add("Title is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Description)) + { + errors.Add("Description is required"); + } + + if (string.IsNullOrWhiteSpace(entity.RequestType)) + { + errors.Add("Request type is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Priority)) + { + errors.Add("Priority is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Status)) + { + errors.Add("Status is required"); + } + + // Validate priority + var validPriorities = new[] { "Low", "Medium", "High", "Urgent" }; + if (!validPriorities.Contains(entity.Priority)) + { + errors.Add($"Priority must be one of: {string.Join(", ", validPriorities)}"); + } + + // Validate status + var validStatuses = new[] { "Submitted", "In Progress", "Completed", "Cancelled" }; + if (!validStatuses.Contains(entity.Status)) + { + errors.Add($"Status must be one of: {string.Join(", ", validStatuses)}"); + } + + // Validate dates + if (entity.RequestedOn > DateTime.Today) + { + errors.Add("Requested date cannot be in the future"); + } + + if (entity.ScheduledOn.HasValue && entity.ScheduledOn.Value.Date < entity.RequestedOn.Date) + { + errors.Add("Scheduled date cannot be before requested date"); + } + + if (entity.CompletedOn.HasValue && entity.CompletedOn.Value.Date < entity.RequestedOn.Date) + { + errors.Add("Completed date cannot be before requested date"); + } + + // Validate costs + if (entity.EstimatedCost < 0) + { + errors.Add("Estimated cost cannot be negative"); + } + + if (entity.ActualCost < 0) + { + errors.Add("Actual cost cannot be negative"); + } + + // Validate status-specific rules + if (entity.Status == "Completed") + { + if (!entity.CompletedOn.HasValue) + { + errors.Add("Completed date is required when status is Completed"); + } + } + + // Verify property exists and belongs to organization + if (entity.PropertyId != Guid.Empty) + { + var property = await _context.Properties + .FirstOrDefaultAsync(p => p.Id == entity.PropertyId && !p.IsDeleted); + + if (property == null) + { + errors.Add($"Property with ID {entity.PropertyId} not found"); + } + else if (property.OrganizationId != entity.OrganizationId) + { + errors.Add("Property does not belong to the same organization"); + } + } + + // If LeaseId is provided, verify it exists and belongs to the same property + if (entity.LeaseId.HasValue && entity.LeaseId.Value != Guid.Empty) + { + var lease = await _context.Leases + .FirstOrDefaultAsync(l => l.Id == entity.LeaseId.Value && !l.IsDeleted); + + if (lease == null) + { + errors.Add($"Lease with ID {entity.LeaseId.Value} not found"); + } + else if (lease.PropertyId != entity.PropertyId) + { + errors.Add("Lease does not belong to the specified property"); + } + else if (lease.OrganizationId != entity.OrganizationId) + { + errors.Add("Lease does not belong to the same organization"); + } + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await Task.CompletedTask; + } + + /// + /// Creates a maintenance request and automatically creates a calendar event. + /// + public override async Task CreateAsync(MaintenanceRequest entity) + { + var maintenanceRequest = await base.CreateAsync(entity); + + // Create calendar event for the maintenance request + await _calendarEventService.CreateOrUpdateEventAsync(maintenanceRequest); + + return maintenanceRequest; + } + + /// + /// Updates a maintenance request and synchronizes the calendar event. + /// + public override async Task UpdateAsync(MaintenanceRequest entity) + { + var maintenanceRequest = await base.UpdateAsync(entity); + + // Update calendar event + await _calendarEventService.CreateOrUpdateEventAsync(maintenanceRequest); + + return maintenanceRequest; + } + + /// + /// Deletes a maintenance request and removes the associated calendar event. + /// + public override async Task DeleteAsync(Guid id) + { + var maintenanceRequest = await GetByIdAsync(id); + + var result = await base.DeleteAsync(id); + + if (result && maintenanceRequest != null) + { + // Delete associated calendar event + await _calendarEventService.DeleteEventAsync(maintenanceRequest.CalendarEventId); + } + + return result; + } + + /// + /// Gets all maintenance requests for a specific property. + /// + public async Task> GetMaintenanceRequestsByPropertyAsync(Guid propertyId) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.PropertyId == propertyId && + m.OrganizationId == organizationId && + !m.IsDeleted) + .OrderByDescending(m => m.RequestedOn) + .ToListAsync(); + } + + /// + /// Gets all maintenance requests for a specific lease. + /// + public async Task> GetMaintenanceRequestsByLeaseAsync(Guid leaseId) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.LeaseId == leaseId && + m.OrganizationId == organizationId && + !m.IsDeleted) + .OrderByDescending(m => m.RequestedOn) + .ToListAsync(); + } + + /// + /// Gets maintenance requests by status. + /// + public async Task> GetMaintenanceRequestsByStatusAsync(string status) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.Status == status && + m.OrganizationId == organizationId && + !m.IsDeleted) + .OrderByDescending(m => m.RequestedOn) + .ToListAsync(); + } + + /// + /// Gets maintenance requests by priority level. + /// + public async Task> GetMaintenanceRequestsByPriorityAsync(string priority) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.Priority == priority && + m.OrganizationId == organizationId && + !m.IsDeleted) + .OrderByDescending(m => m.RequestedOn) + .ToListAsync(); + } + + /// + /// Gets overdue maintenance requests (scheduled date has passed but not completed). + /// + public async Task> GetOverdueMaintenanceRequestsAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var today = DateTime.Today; + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.OrganizationId == organizationId && + !m.IsDeleted && + m.Status != "Completed" && + m.Status != "Cancelled" && + m.ScheduledOn.HasValue && + m.ScheduledOn.Value.Date < today) + .OrderBy(m => m.ScheduledOn) + .ToListAsync(); + } + + /// + /// Gets the count of open (not completed/cancelled) maintenance requests. + /// + public async Task GetOpenMaintenanceRequestCountAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + !m.IsDeleted && + m.Status != "Completed" && + m.Status != "Cancelled") + .CountAsync(); + } + + /// + /// Gets the count of urgent priority maintenance requests. + /// + public async Task GetUrgentMaintenanceRequestCountAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + !m.IsDeleted && + m.Priority == "Urgent" && + m.Status != "Completed" && + m.Status != "Cancelled") + .CountAsync(); + } + + /// + /// Gets a maintenance request with all related entities loaded. + /// + public async Task GetMaintenanceRequestWithRelationsAsync(Guid id) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .ThenInclude(l => l!.Tenant) + .FirstOrDefaultAsync(m => m.Id == id && + m.OrganizationId == organizationId && + !m.IsDeleted); + } + + /// + /// Updates the status of a maintenance request with automatic date tracking. + /// + public async Task UpdateMaintenanceRequestStatusAsync(Guid id, string status) + { + var maintenanceRequest = await GetByIdAsync(id); + + if (maintenanceRequest == null) + { + throw new ValidationException($"Maintenance request {id} not found"); + } + + maintenanceRequest.Status = status; + + // Auto-set completed date when marked as completed + if (status == "Completed" && !maintenanceRequest.CompletedOn.HasValue) + { + maintenanceRequest.CompletedOn = DateTime.Today; + } + + return await UpdateAsync(maintenanceRequest); + } + + /// + /// Assigns a maintenance request to a contractor or maintenance person. + /// + public async Task AssignMaintenanceRequestAsync(Guid id, string assignedTo, DateTime? scheduledOn = null) + { + var maintenanceRequest = await GetByIdAsync(id); + + if (maintenanceRequest == null) + { + throw new ValidationException($"Maintenance request {id} not found"); + } + + maintenanceRequest.AssignedTo = assignedTo; + + if (scheduledOn.HasValue) + { + maintenanceRequest.ScheduledOn = scheduledOn.Value; + } + + // Auto-update status to In Progress if still Submitted + if (maintenanceRequest.Status == "Submitted") + { + maintenanceRequest.Status = "In Progress"; + } + + return await UpdateAsync(maintenanceRequest); + } + + /// + /// Completes a maintenance request with actual cost and resolution notes. + /// + public async Task CompleteMaintenanceRequestAsync( + Guid id, + decimal actualCost, + string resolutionNotes) + { + var maintenanceRequest = await GetByIdAsync(id); + + if (maintenanceRequest == null) + { + throw new ValidationException($"Maintenance request {id} not found"); + } + + maintenanceRequest.Status = "Completed"; + maintenanceRequest.CompletedOn = DateTime.Today; + maintenanceRequest.ActualCost = actualCost; + maintenanceRequest.ResolutionNotes = resolutionNotes; + + return await UpdateAsync(maintenanceRequest); + } + + /// + /// Gets maintenance requests assigned to a specific person. + /// + public async Task> GetMaintenanceRequestsByAssigneeAsync(string assignedTo) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.MaintenanceRequests + .Include(m => m.Property) + .Include(m => m.Lease) + .Where(m => m.AssignedTo == assignedTo && + m.OrganizationId == organizationId && + !m.IsDeleted && + m.Status != "Completed" && + m.Status != "Cancelled") + .OrderByDescending(m => m.Priority == "Urgent") + .ThenByDescending(m => m.Priority == "High") + .ThenBy(m => m.ScheduledOn) + .ToListAsync(); + } + + /// + /// Calculates average days to complete maintenance requests. + /// + public async Task CalculateAverageDaysToCompleteAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var completedRequests = await _context.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + !m.IsDeleted && + m.Status == "Completed" && + m.CompletedOn.HasValue) + .Select(m => new { m.RequestedOn, m.CompletedOn }) + .ToListAsync(); + + if (!completedRequests.Any()) + { + return 0; + } + + var totalDays = completedRequests.Sum(r => (r.CompletedOn!.Value.Date - r.RequestedOn.Date).Days); + return (double)totalDays / completedRequests.Count; + } + + /// + /// Gets maintenance cost summary by property. + /// + public async Task> GetMaintenanceCostsByPropertyAsync(DateTime? startDate = null, DateTime? endDate = null) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var query = _context.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + !m.IsDeleted && + m.Status == "Completed"); + + if (startDate.HasValue) + { + query = query.Where(m => m.CompletedOn >= startDate.Value); + } + + if (endDate.HasValue) + { + query = query.Where(m => m.CompletedOn <= endDate.Value); + } + + return await query + .GroupBy(m => m.PropertyId) + .Select(g => new { PropertyId = g.Key, TotalCost = g.Sum(m => m.ActualCost) }) + .ToDictionaryAsync(x => x.PropertyId, x => x.TotalCost); + } + } +} diff --git a/Aquiis.SimpleStart/Application/Services/PaymentService.cs b/Aquiis.SimpleStart/Application/Services/PaymentService.cs new file mode 100644 index 0000000..36da8b1 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/PaymentService.cs @@ -0,0 +1,409 @@ +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Payment entities. + /// Inherits common CRUD operations from BaseService and adds payment-specific business logic. + /// + public class PaymentService : BaseService + { + public PaymentService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + /// + /// Validates a payment before create/update operations. + /// + protected override async Task ValidateEntityAsync(Payment entity) + { + var errors = new List(); + + // Required fields + if (entity.InvoiceId == Guid.Empty) + { + errors.Add("Invoice ID is required."); + } + + if (entity.Amount <= 0) + { + errors.Add("Payment amount must be greater than zero."); + } + + if (entity.PaidOn > DateTime.UtcNow.Date.AddDays(1)) + { + errors.Add("Payment date cannot be in the future."); + } + + // Validate invoice exists and belongs to organization + if (entity.InvoiceId != Guid.Empty) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var invoice = await _context.Invoices + .Include(i => i.Lease) + .ThenInclude(l => l.Property) + .FirstOrDefaultAsync(i => i.Id == entity.InvoiceId && !i.IsDeleted); + + if (invoice == null) + { + errors.Add($"Invoice with ID {entity.InvoiceId} does not exist."); + } + else if (invoice.Lease?.Property?.OrganizationId != organizationId) + { + errors.Add("Invoice does not belong to the current organization."); + } + else + { + // Validate payment doesn't exceed invoice balance + var existingPayments = await _context.Payments + .Where(p => p.InvoiceId == entity.InvoiceId + && !p.IsDeleted + && p.Id != entity.Id) // Exclude current payment for updates + .SumAsync(p => p.Amount); + + var totalWithThisPayment = existingPayments + entity.Amount; + var invoiceTotal = invoice.Amount + (invoice.LateFeeAmount ?? 0); + + if (totalWithThisPayment > invoiceTotal) + { + errors.Add($"Payment amount would exceed invoice balance. Invoice total: {invoiceTotal:C}, Already paid: {existingPayments:C}, This payment: {entity.Amount:C}"); + } + } + } + + // Validate payment method + var validMethods = new[] { "Cash", "Check", "CreditCard", "BankTransfer", "ACH", "Wire", "MoneyOrder", "Other" }; + if (!string.IsNullOrWhiteSpace(entity.PaymentMethod) && !validMethods.Contains(entity.PaymentMethod)) + { + errors.Add($"Payment method must be one of: {string.Join(", ", validMethods)}"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join(" ", errors)); + } + } + + /// + /// Creates a payment and automatically updates the associated invoice. + /// + public override async Task CreateAsync(Payment entity) + { + var payment = await base.CreateAsync(entity); + await UpdateInvoiceAfterPaymentChangeAsync(payment.InvoiceId); + return payment; + } + + /// + /// Updates a payment and automatically updates the associated invoice. + /// + public override async Task UpdateAsync(Payment entity) + { + var payment = await base.UpdateAsync(entity); + await UpdateInvoiceAfterPaymentChangeAsync(payment.InvoiceId); + return payment; + } + + /// + /// Deletes a payment and automatically updates the associated invoice. + /// + public override async Task DeleteAsync(Guid id) + { + var payment = await GetByIdAsync(id); + if (payment != null) + { + var invoiceId = payment.InvoiceId; + var result = await base.DeleteAsync(id); + await UpdateInvoiceAfterPaymentChangeAsync(invoiceId); + return result; + } + return false; + } + + /// + /// Gets all payments for a specific invoice. + /// + public async Task> GetPaymentsByInvoiceIdAsync(Guid invoiceId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Payments + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Where(p => p.InvoiceId == invoiceId + && !p.IsDeleted + && p.OrganizationId == organizationId) + .OrderByDescending(p => p.PaidOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPaymentsByInvoiceId"); + throw; + } + } + + /// + /// Gets payments by payment method. + /// + public async Task> GetPaymentsByMethodAsync(string paymentMethod) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Payments + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Where(p => p.PaymentMethod == paymentMethod + && !p.IsDeleted + && p.OrganizationId == organizationId) + .OrderByDescending(p => p.PaidOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPaymentsByMethod"); + throw; + } + } + + /// + /// Gets payments within a specific date range. + /// + public async Task> GetPaymentsByDateRangeAsync(DateTime startDate, DateTime endDate) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Payments + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Where(p => p.PaidOn >= startDate + && p.PaidOn <= endDate + && !p.IsDeleted + && p.OrganizationId == organizationId) + .OrderByDescending(p => p.PaidOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPaymentsByDateRange"); + throw; + } + } + + /// + /// Gets a payment with all related entities loaded. + /// + public async Task GetPaymentWithRelationsAsync(Guid paymentId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Payments + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Property) + .Include(p => p.Invoice) + .ThenInclude(i => i.Lease) + .ThenInclude(l => l.Tenant) + .Include(p => p.Document) + .FirstOrDefaultAsync(p => p.Id == paymentId + && !p.IsDeleted + && p.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPaymentWithRelations"); + throw; + } + } + + /// + /// Calculates the total payments received within a date range. + /// + public async Task CalculateTotalPaymentsAsync(DateTime? startDate = null, DateTime? endDate = null) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var query = _context.Payments + .Where(p => !p.IsDeleted && p.OrganizationId == organizationId); + + if (startDate.HasValue) + { + query = query.Where(p => p.PaidOn >= startDate.Value); + } + + if (endDate.HasValue) + { + query = query.Where(p => p.PaidOn <= endDate.Value); + } + + return await query.SumAsync(p => p.Amount); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateTotalPayments"); + throw; + } + } + + /// + /// Gets payment summary grouped by payment method. + /// + public async Task> GetPaymentSummaryByMethodAsync(DateTime? startDate = null, DateTime? endDate = null) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var query = _context.Payments + .Where(p => !p.IsDeleted && p.OrganizationId == organizationId); + + if (startDate.HasValue) + { + query = query.Where(p => p.PaidOn >= startDate.Value); + } + + if (endDate.HasValue) + { + query = query.Where(p => p.PaidOn <= endDate.Value); + } + + return await query + .GroupBy(p => p.PaymentMethod) + .Select(g => new { Method = g.Key, Total = g.Sum(p => p.Amount) }) + .ToDictionaryAsync(x => x.Method, x => x.Total); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPaymentSummaryByMethod"); + throw; + } + } + + /// + /// Gets the total amount paid for a specific invoice. + /// + public async Task GetTotalPaidForInvoiceAsync(Guid invoiceId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Payments + .Where(p => p.InvoiceId == invoiceId + && !p.IsDeleted + && p.OrganizationId == organizationId) + .SumAsync(p => p.Amount); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTotalPaidForInvoice"); + throw; + } + } + + /// + /// Updates the invoice status and paid amount after a payment change. + /// + private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + var invoice = await _context.Invoices + .Include(i => i.Payments) + .FirstOrDefaultAsync(i => i.Id == invoiceId && i.OrganizationId == organizationId); + + if (invoice != null) + { + var totalPaid = invoice.Payments + .Where(p => !p.IsDeleted) + .Sum(p => p.Amount); + + invoice.AmountPaid = totalPaid; + + var totalDue = invoice.Amount + (invoice.LateFeeAmount ?? 0); + + // Update invoice status based on payment + if (totalPaid >= totalDue) + { + invoice.Status = "Paid"; + invoice.PaidOn = invoice.Payments + .Where(p => !p.IsDeleted) + .OrderByDescending(p => p.PaidOn) + .FirstOrDefault()?.PaidOn ?? DateTime.UtcNow; + } + else if (totalPaid > 0 && invoice.Status != "Cancelled") + { + // Invoice is partially paid + if (invoice.DueOn < DateTime.Today) + { + invoice.Status = "Overdue"; + } + else + { + invoice.Status = "Pending"; + } + } + else if (invoice.Status != "Cancelled") + { + // No payments + if (invoice.DueOn < DateTime.Today) + { + invoice.Status = "Overdue"; + } + else + { + invoice.Status = "Pending"; + } + } + + var userId = await _userContext.GetUserIdAsync(); + invoice.LastModifiedBy = userId ?? "system"; + invoice.LastModifiedOn = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + } + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateInvoiceAfterPaymentChange"); + throw; + } + } + } +} diff --git a/Aquiis.SimpleStart/Application/Services/TenantService.cs b/Aquiis.SimpleStart/Application/Services/TenantService.cs new file mode 100644 index 0000000..20f705d --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/TenantService.cs @@ -0,0 +1,418 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing Tenant entities. + /// Inherits common CRUD operations from BaseService and adds tenant-specific business logic. + /// + public class TenantService : BaseService + { + public TenantService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with Tenant-Specific Logic + + /// + /// Retrieves a tenant by ID with related entities (Leases). + /// + public async Task GetTenantWithRelationsAsync(Guid tenantId) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Include(t => t.Leases) + .FirstOrDefaultAsync(t => t.Id == tenantId && + t.OrganizationId == organizationId && + !t.IsDeleted); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantWithRelations"); + throw; + } + } + + /// + /// Retrieves all tenants with related entities. + /// + public async Task> GetTenantsWithRelationsAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Include(t => t.Leases) + .Where(t => !t.IsDeleted && t.OrganizationId == organizationId) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantsWithRelations"); + throw; + } + } + + /// + /// Validates tenant data before create/update operations. + /// + protected override async Task ValidateEntityAsync(Tenant tenant) + { + // Validate required email + if (string.IsNullOrWhiteSpace(tenant.Email)) + { + throw new ValidationException("Tenant email is required."); + } + + // Validate required identification number + if (string.IsNullOrWhiteSpace(tenant.IdentificationNumber)) + { + throw new ValidationException("Tenant identification number is required."); + } + + // Check for duplicate email in same organization + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var emailExists = await _context.Tenants + .AnyAsync(t => t.Email == tenant.Email && + t.Id != tenant.Id && + t.OrganizationId == organizationId && + !t.IsDeleted); + + if (emailExists) + { + throw new ValidationException($"A tenant with email '{tenant.Email}' already exists."); + } + + // Check for duplicate identification number in same organization + var idNumberExists = await _context.Tenants + .AnyAsync(t => t.IdentificationNumber == tenant.IdentificationNumber && + t.Id != tenant.Id && + t.OrganizationId == organizationId && + !t.IsDeleted); + + if (idNumberExists) + { + throw new ValidationException($"A tenant with identification number '{tenant.IdentificationNumber}' already exists."); + } + + await base.ValidateEntityAsync(tenant); + } + + #endregion + + #region Business Logic Methods + + /// + /// Retrieves a tenant by identification number. + /// + public async Task GetTenantByIdentificationNumberAsync(string identificationNumber) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Include(t => t.Leases) + .FirstOrDefaultAsync(t => t.IdentificationNumber == identificationNumber && + t.OrganizationId == organizationId && + !t.IsDeleted); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantByIdentificationNumber"); + throw; + } + } + + /// + /// Retrieves a tenant by email address. + /// + public async Task GetTenantByEmailAsync(string email) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Include(t => t.Leases) + .FirstOrDefaultAsync(t => t.Email == email && + t.OrganizationId == organizationId && + !t.IsDeleted); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantByEmail"); + throw; + } + } + + /// + /// Retrieves all active tenants (IsActive = true). + /// + public async Task> GetActiveTenantsAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Where(t => !t.IsDeleted && + t.IsActive && + t.OrganizationId == organizationId) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetActiveTenants"); + throw; + } + } + + /// + /// Retrieves all tenants with active leases. + /// + public async Task> GetTenantsWithActiveLeasesAsync() + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.Tenants + .Where(t => !t.IsDeleted && + t.OrganizationId == organizationId) + .Where(t => _context.Leases.Any(l => + l.TenantId == t.Id && + l.Status == ApplicationConstants.LeaseStatuses.Active && + !l.IsDeleted)) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantsWithActiveLeases"); + throw; + } + } + + /// + /// Retrieves tenants by property ID (via their leases). + /// + public async Task> GetTenantsByPropertyIdAsync(Guid propertyId) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var leases = await _context.Leases + .Include(l => l.Tenant) + .Where(l => l.PropertyId == propertyId && + l.Tenant!.OrganizationId == organizationId && + !l.IsDeleted && + !l.Tenant.IsDeleted) + .ToListAsync(); + + var tenantIds = leases.Select(l => l.TenantId).Distinct().ToList(); + + return await _context.Tenants + .Where(t => tenantIds.Contains(t.Id) && + t.OrganizationId == organizationId && + !t.IsDeleted) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantsByPropertyId"); + throw; + } + } + + /// + /// Retrieves tenants by lease ID. + /// + public async Task> GetTenantsByLeaseIdAsync(Guid leaseId) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + var leases = await _context.Leases + .Include(l => l.Tenant) + .Where(l => l.Id == leaseId && + l.Tenant!.OrganizationId == organizationId && + !l.IsDeleted && + !l.Tenant.IsDeleted) + .ToListAsync(); + + var tenantIds = leases.Select(l => l.TenantId).Distinct().ToList(); + + return await _context.Tenants + .Where(t => tenantIds.Contains(t.Id) && + t.OrganizationId == organizationId && + !t.IsDeleted) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetTenantsByLeaseId"); + throw; + } + } + + /// + /// Searches tenants by name, email, or identification number. + /// + public async Task> SearchTenantsAsync(string searchTerm) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return await _context.Tenants + .Where(t => !t.IsDeleted && t.OrganizationId == organizationId) + .OrderBy(t => t.LastName) + .ThenBy(t => t.FirstName) + .Take(20) + .ToListAsync(); + } + + return await _context.Tenants + .Where(t => !t.IsDeleted && + t.OrganizationId == organizationId && + (t.FirstName.Contains(searchTerm) || + t.LastName.Contains(searchTerm) || + t.Email.Contains(searchTerm) || + t.IdentificationNumber.Contains(searchTerm))) + .OrderBy(t => t.LastName) + .ThenBy(t => t.FirstName) + .Take(20) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "SearchTenants"); + throw; + } + } + + /// + /// Calculates the total outstanding balance for a tenant across all their leases. + /// + public async Task CalculateTenantBalanceAsync(Guid tenantId) + { + try + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + // Verify tenant exists and belongs to organization + var tenant = await GetByIdAsync(tenantId); + if (tenant == null) + { + throw new InvalidOperationException($"Tenant not found: {tenantId}"); + } + + // Calculate total invoiced amount + var totalInvoiced = await _context.Invoices + .Where(i => i.Lease.TenantId == tenantId && + i.Lease.Property.OrganizationId == organizationId && + !i.IsDeleted && + !i.Lease.IsDeleted) + .SumAsync(i => i.Amount); + + // Calculate total paid amount + var totalPaid = await _context.Payments + .Where(p => p.Invoice.Lease.TenantId == tenantId && + p.Invoice.Lease.Property.OrganizationId == organizationId && + !p.IsDeleted && + !p.Invoice.IsDeleted) + .SumAsync(p => p.Amount); + + return totalInvoiced - totalPaid; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CalculateTenantBalance"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Core/Interfaces/ICalendarEventService.cs b/Aquiis.SimpleStart/Core/Interfaces/ICalendarEventService.cs new file mode 100644 index 0000000..d65acb0 --- /dev/null +++ b/Aquiis.SimpleStart/Core/Interfaces/ICalendarEventService.cs @@ -0,0 +1,21 @@ +using Aquiis.SimpleStart.Core.Entities; + +namespace Aquiis.SimpleStart.Core.Interfaces +{ + /// + /// Service interface for managing calendar events and synchronizing with schedulable entities + /// + public interface ICalendarEventService + { + /// + /// Create or update a calendar event from a schedulable entity + /// + Task CreateOrUpdateEventAsync(T entity) + where T : BaseModel, ISchedulableEntity; + + /// + /// Delete a calendar event + /// + Task DeleteEventAsync(Guid? calendarEventId); + } +} diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor index 1db2c2c..270c896 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor @@ -16,7 +16,7 @@ @inject UserContextService UserContext @inject NavigationManager NavigationManager @inject ChecklistPdfGenerator PdfGenerator -@inject DocumentService DocumentService +@inject Aquiis.SimpleStart.Shared.Services.DocumentService DocumentService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer diff --git a/Aquiis.SimpleStart/Program.cs b/Aquiis.SimpleStart/Program.cs index f83000c..1040a59 100644 --- a/Aquiis.SimpleStart/Program.cs +++ b/Aquiis.SimpleStart/Program.cs @@ -142,13 +142,19 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Existing PDF service builder.Services.AddScoped(); // Workflow services From d1c55542ccbc12459daec711e40264132e8e3b67 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 10:23:39 -0600 Subject: [PATCH 4/9] See REVISIONS.md for details --- Aquiis.SimpleStart/electron.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Aquiis.SimpleStart/electron.manifest.json b/Aquiis.SimpleStart/electron.manifest.json index 95946cd..771f66b 100644 --- a/Aquiis.SimpleStart/electron.manifest.json +++ b/Aquiis.SimpleStart/electron.manifest.json @@ -1,7 +1,7 @@ { "executable": "Aquiis.SimpleStart", "splashscreen": { - "imageFile": "Assets/splash.png" + "imageFile": "wwwroot/assets/splash.png" }, "name": "aquiis-property-management", "author": "Aquiis", From 4c398f3d9f6a99d2ce427593c7e5244d028fd47c Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 18:20:10 -0600 Subject: [PATCH 5/9] Phase 1.C and 1.D - Migrate razor components and test complete. --- .../Services/ApplicationService.cs | 17 +- .../Application/Services/DocumentService.cs | 13 + .../Application/Services/InspectionService.cs | 276 ++++++++++ .../Application/Services/LeaseOfferService.cs | 294 +++++++++++ .../Services/OrganizationService.cs | 44 ++ .../Application/Services/PaymentService.cs | 3 +- .../Application/Services/PropertyService.cs | 50 ++ .../Services/ProspectiveTenantService.cs | 218 ++++++++ .../Services/RentalApplicationService.cs | 273 ++++++++++ .../Services/ScheduledTaskService.cs | 20 +- .../Application/Services/ScreeningService.cs | 237 +++++++++ .../Application/Services/TourService.cs | 490 ++++++++++++++++++ .../Core/Services/BaseService.cs | 37 +- .../Settings/Pages/LateFeeSettings.razor | 8 +- .../Settings/Pages/OrganizationSettings.razor | 8 +- .../Applications/Pages/Applications.razor | 4 +- .../Pages/GenerateLeaseOffer.razor | 4 +- .../Pages/ProspectiveTenants.razor | 11 +- .../Pages/ReviewApplication.razor | 16 +- .../Applications/Pages/ScheduleTour.razor | 12 +- .../Pages/SubmitApplication.razor | 13 +- .../Applications/Pages/Tours.razor | 10 +- .../Applications/Pages/ToursCalendar.razor | 8 +- .../Pages/ViewProspectiveTenant.razor | 15 +- .../PropertyManagement/Calendar.razor | 31 +- .../PropertyManagement/CalendarListView.razor | 32 +- .../Checklists/Pages/Complete.razor | 7 +- .../Checklists/Pages/Create.razor | 1 - .../Checklists/Pages/View.razor | 10 +- .../Documents/Pages/Documents.razor | 12 +- .../Documents/Pages/LeaseDocuments.razor | 11 +- .../Inspections/Pages/Create.razor | 10 +- .../Inspections/Pages/Schedule.razor | 8 +- .../Inspections/Pages/View.razor | 11 +- .../Invoices/Pages/CreateInvoice.razor | 9 +- .../Invoices/Pages/EditInvoice.razor | 9 +- .../Invoices/Pages/Invoices.razor | 6 +- .../Invoices/Pages/ViewInvoice.razor | 11 +- .../LeaseOffers/Pages/LeaseOffers.razor | 7 +- .../LeaseOffers/Pages/ViewLeaseOffer.razor | 4 +- .../Leases/Pages/AcceptLease.razor | 29 +- .../Leases/Pages/CreateLease.razor | 19 +- .../Leases/Pages/EditLease.razor | 12 +- .../Leases/Pages/Leases.razor | 12 +- .../Leases/Pages/ViewLease.razor | 23 +- .../Pages/CreateMaintenanceRequest.razor | 10 +- .../Pages/EditMaintenanceRequest.razor | 14 +- .../Pages/MaintenanceRequests.razor | 6 +- .../Pages/ViewMaintenanceRequest.razor | 6 +- .../Payments/Pages/CreatePayment.razor | 7 +- .../Payments/Pages/EditPayment.razor | 6 +- .../Payments/Pages/Payments.razor | 6 +- .../Payments/Pages/ViewPayment.razor | 11 +- .../Properties/Pages/Create.razor | 4 +- .../Properties/Pages/Edit.razor | 10 +- .../Properties/Pages/Index.razor | 6 +- .../Properties/Pages/View.razor | 22 +- .../Reports/Pages/IncomeStatementReport.razor | 4 +- .../Reports/Pages/TaxReport.razor | 4 +- .../Pages/RecordPoolPerformance.razor | 4 +- .../Tenants/Pages/Create.razor | 6 +- .../Tenants/Pages/EditTenant.razor | 8 +- .../Tenants/Pages/Tenants.razor | 8 +- .../Tenants/Pages/View.razor | 7 +- .../Data/ApplicationDbContext.cs | 10 + Aquiis.SimpleStart/Program.cs | 11 +- .../Components/LeaseRenewalWidget.razor | 4 +- .../Shared/Components/Pages/Home.razor | 16 +- .../Shared/Services/DocumentService.cs | 46 -- 69 files changed, 2271 insertions(+), 320 deletions(-) create mode 100644 Aquiis.SimpleStart/Application/Services/InspectionService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/ProspectiveTenantService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/RentalApplicationService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/ScreeningService.cs create mode 100644 Aquiis.SimpleStart/Application/Services/TourService.cs delete mode 100644 Aquiis.SimpleStart/Shared/Services/DocumentService.cs diff --git a/Aquiis.SimpleStart/Application/Services/ApplicationService.cs b/Aquiis.SimpleStart/Application/Services/ApplicationService.cs index e12ace7..cab5367 100644 --- a/Aquiis.SimpleStart/Application/Services/ApplicationService.cs +++ b/Aquiis.SimpleStart/Application/Services/ApplicationService.cs @@ -7,16 +7,19 @@ namespace Aquiis.SimpleStart.Application.Services public class ApplicationService { private readonly ApplicationSettings _settings; - private readonly PropertyManagementService _propertyManagementService; + private readonly PaymentService _paymentService; + private readonly LeaseService _leaseService; public bool SoftDeleteEnabled { get; } public ApplicationService( IOptions settings, - PropertyManagementService propertyManagementService) + PaymentService paymentService, + LeaseService leaseService) { _settings = settings.Value; - _propertyManagementService = propertyManagementService; + _paymentService = paymentService; + _leaseService = leaseService; SoftDeleteEnabled = _settings.SoftDeleteEnabled; } @@ -30,7 +33,7 @@ public string GetAppInfo() /// public async Task GetDailyPaymentTotalAsync(DateTime date) { - var payments = await _propertyManagementService.GetPaymentsAsync(); + var payments = await _paymentService.GetAllAsync(); return payments .Where(p => p.PaidOn.Date == date.Date && !p.IsDeleted) .Sum(p => p.Amount); @@ -49,7 +52,7 @@ public async Task GetTodayPaymentTotalAsync() /// public async Task GetPaymentTotalForRangeAsync(DateTime startDate, DateTime endDate) { - var payments = await _propertyManagementService.GetPaymentsAsync(); + var payments = await _paymentService.GetAllAsync(); return payments .Where(p => p.PaidOn.Date >= startDate.Date && p.PaidOn.Date <= endDate.Date && @@ -62,7 +65,7 @@ public async Task GetPaymentTotalForRangeAsync(DateTime startDate, Date /// public async Task GetPaymentStatisticsAsync(DateTime startDate, DateTime endDate) { - var payments = await _propertyManagementService.GetPaymentsAsync(); + var payments = await _paymentService.GetAllAsync(); var periodPayments = payments .Where(p => p.PaidOn.Date >= startDate.Date && p.PaidOn.Date <= endDate.Date && @@ -87,7 +90,7 @@ public async Task GetPaymentStatisticsAsync(DateTime startDat /// public async Task GetLeasesExpiringCountAsync(int daysAhead) { - var leases = await _propertyManagementService.GetLeasesAsync(); + var leases = await _leaseService.GetAllAsync(); return leases .Where(l => l.EndDate >= DateTime.Today && l.EndDate <= DateTime.Today.AddDays(daysAhead) && diff --git a/Aquiis.SimpleStart/Application/Services/DocumentService.cs b/Aquiis.SimpleStart/Application/Services/DocumentService.cs index 36671c3..351227d 100644 --- a/Aquiis.SimpleStart/Application/Services/DocumentService.cs +++ b/Aquiis.SimpleStart/Application/Services/DocumentService.cs @@ -3,6 +3,7 @@ using Aquiis.SimpleStart.Core.Services; using Aquiis.SimpleStart.Infrastructure.Data; using Aquiis.SimpleStart.Shared.Services; +using Aquiis.SimpleStart.Application.Services.PdfGenerators; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -415,5 +416,17 @@ public async Task> GetDocumentCountByTypeAsync() } #endregion + + #region PDF Generation Methods + + /// + /// Generates a lease document PDF. + /// + public async Task GenerateLeaseDocumentAsync(Lease lease) + { + return await LeasePdfGenerator.GenerateLeasePdf(lease); + } + + #endregion } } diff --git a/Aquiis.SimpleStart/Application/Services/InspectionService.cs b/Aquiis.SimpleStart/Application/Services/InspectionService.cs new file mode 100644 index 0000000..c3f4270 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/InspectionService.cs @@ -0,0 +1,276 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing property inspections with business logic for scheduling, + /// tracking, and integration with calendar events. + /// + public class InspectionService : BaseService + { + private readonly ICalendarEventService _calendarEventService; + + public InspectionService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings, + ICalendarEventService calendarEventService) + : base(context, logger, userContext, settings) + { + _calendarEventService = calendarEventService; + } + + #region Helper Methods + + protected async Task GetUserIdAsync() + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + return userId; + } + + protected async Task GetActiveOrganizationIdAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + if (!organizationId.HasValue) + { + throw new UnauthorizedAccessException("No active organization."); + } + return organizationId.Value; + } + + #endregion + + /// + /// Validates inspection business rules. + /// + protected override async Task ValidateEntityAsync(Inspection entity) + { + var errors = new List(); + + // Required fields + if (entity.PropertyId == Guid.Empty) + { + errors.Add("Property is required"); + } + + if (string.IsNullOrWhiteSpace(entity.InspectionType)) + { + errors.Add("Inspection type is required"); + } + + if (entity.CompletedOn == default) + { + errors.Add("Completion date is required"); + } + + if (errors.Any()) + { + throw new InvalidOperationException(string.Join("; ", errors)); + } + + await Task.CompletedTask; + } + + /// + /// Gets all inspections for the active organization. + /// + public override async Task> GetAllAsync() + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Inspections + .Include(i => i.Property) + .Include(i => i.Lease) + .ThenInclude(l => l!.Tenant) + .Where(i => !i.IsDeleted && i.OrganizationId == organizationId) + .OrderByDescending(i => i.CompletedOn) + .ToListAsync(); + } + + /// + /// Gets inspections by property ID. + /// + public async Task> GetByPropertyIdAsync(Guid propertyId) + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Inspections + .Include(i => i.Property) + .Include(i => i.Lease) + .ThenInclude(l => l!.Tenant) + .Where(i => i.PropertyId == propertyId && !i.IsDeleted && i.OrganizationId == organizationId) + .OrderByDescending(i => i.CompletedOn) + .ToListAsync(); + } + + /// + /// Gets a single inspection by ID with related data. + /// + public override async Task GetByIdAsync(Guid id) + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Inspections + .Include(i => i.Property) + .Include(i => i.Lease) + .ThenInclude(l => l!.Tenant) + .FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted && i.OrganizationId == organizationId); + } + + /// + /// Creates a new inspection with calendar event integration. + /// + public override async Task CreateAsync(Inspection inspection) + { + // Base validation and creation + await ValidateEntityAsync(inspection); + + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + inspection.Id = Guid.NewGuid(); + inspection.OrganizationId = organizationId; + inspection.CreatedBy = userId; + inspection.CreatedOn = DateTime.UtcNow; + + await _context.Inspections.AddAsync(inspection); + await _context.SaveChangesAsync(); + + // Create calendar event for the inspection + await _calendarEventService.CreateOrUpdateEventAsync(inspection); + + // Update property inspection tracking if this is a routine inspection + if (inspection.InspectionType == ApplicationConstants.InspectionTypes.Routine) + { + await HandleRoutineInspectionCompletionAsync(inspection); + } + + _logger.LogInformation("Created inspection {InspectionId} for property {PropertyId}", + inspection.Id, inspection.PropertyId); + + return inspection; + } + + /// + /// Updates an existing inspection. + /// + public override async Task UpdateAsync(Inspection inspection) + { + await ValidateEntityAsync(inspection); + + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + // Security: Verify inspection belongs to active organization + var existing = await _context.Inspections + .FirstOrDefaultAsync(i => i.Id == inspection.Id && i.OrganizationId == organizationId); + + if (existing == null) + { + throw new UnauthorizedAccessException($"Inspection {inspection.Id} not found in active organization."); + } + + // Set tracking fields + inspection.LastModifiedBy = userId; + inspection.LastModifiedOn = DateTime.UtcNow; + inspection.OrganizationId = organizationId; // Prevent org hijacking + + _context.Entry(existing).CurrentValues.SetValues(inspection); + await _context.SaveChangesAsync(); + + // Update calendar event + await _calendarEventService.CreateOrUpdateEventAsync(inspection); + + // Update property inspection tracking if routine inspection date changed + if (inspection.InspectionType == ApplicationConstants.InspectionTypes.Routine) + { + await HandleRoutineInspectionCompletionAsync(inspection); + } + + _logger.LogInformation("Updated inspection {InspectionId}", inspection.Id); + + return inspection; + } + + /// + /// Deletes an inspection (soft delete). + /// + public override async Task DeleteAsync(Guid id) + { + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + var inspection = await _context.Inspections + .FirstOrDefaultAsync(i => i.Id == id && i.OrganizationId == organizationId); + + if (inspection == null) + { + throw new KeyNotFoundException($"Inspection {id} not found."); + } + + inspection.IsDeleted = true; + inspection.LastModifiedBy = userId; + inspection.LastModifiedOn = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + // TODO: Delete associated calendar event when interface method is available + // await _calendarEventService.DeleteEventBySourceAsync(id, nameof(Inspection)); + + _logger.LogInformation("Deleted inspection {InspectionId}", id); + + return true; + } + + /// + /// Handles routine inspection completion by updating property tracking and removing old calendar events. + /// + private async Task HandleRoutineInspectionCompletionAsync(Inspection inspection) + { + // Find and update/delete the original property-based routine inspection calendar event + var propertyBasedEvent = await _context.CalendarEvents + .FirstOrDefaultAsync(e => + e.PropertyId == inspection.PropertyId && + e.SourceEntityType == "Property" && + e.EventType == CalendarEventTypes.Inspection && + !e.IsDeleted); + + if (propertyBasedEvent != null) + { + // Remove the old property-based event since we now have an actual inspection record + _context.CalendarEvents.Remove(propertyBasedEvent); + } + + // Update property's routine inspection tracking + var property = await _context.Properties + .FirstOrDefaultAsync(p => p.Id == inspection.PropertyId); + + if (property != null) + { + property.LastRoutineInspectionDate = inspection.CompletedOn; + + // Calculate next routine inspection date based on interval + if (property.RoutineInspectionIntervalMonths > 0) + { + property.NextRoutineInspectionDueDate = inspection.CompletedOn + .AddMonths(property.RoutineInspectionIntervalMonths); + } + + await _context.SaveChangesAsync(); + } + } + } +} diff --git a/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs b/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs new file mode 100644 index 0000000..6ad41ff --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs @@ -0,0 +1,294 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing LeaseOffer entities. + /// Inherits common CRUD operations from BaseService and adds lease offer-specific business logic. + /// + public class LeaseOfferService : BaseService + { + public LeaseOfferService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with LeaseOffer-Specific Logic + + /// + /// Validates a lease offer entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(LeaseOffer entity) + { + var errors = new List(); + + // Required field validation + if (entity.RentalApplicationId == Guid.Empty) + { + errors.Add("RentalApplicationId is required"); + } + + if (entity.PropertyId == Guid.Empty) + { + errors.Add("PropertyId is required"); + } + + if (entity.ProspectiveTenantId == Guid.Empty) + { + errors.Add("ProspectiveTenantId is required"); + } + + if (entity.MonthlyRent <= 0) + { + errors.Add("MonthlyRent must be greater than zero"); + } + + if (entity.SecurityDeposit < 0) + { + errors.Add("SecurityDeposit cannot be negative"); + } + + if (entity.OfferedOn == DateTime.MinValue) + { + errors.Add("OfferedOn is required"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + /// + /// Sets default values for create operations. + /// + protected override async Task SetCreateDefaultsAsync(LeaseOffer entity) + { + entity = await base.SetCreateDefaultsAsync(entity); + + // Set default status if not already set + if (string.IsNullOrWhiteSpace(entity.Status)) + { + entity.Status = "Pending"; + } + + // Set offered date if not already set + if (entity.OfferedOn == DateTime.MinValue) + { + entity.OfferedOn = DateTime.UtcNow; + } + + // Set expiration date if not already set (default 7 days) + if (entity.ExpiresOn == DateTime.MinValue) + { + entity.ExpiresOn = entity.OfferedOn.AddDays(7); + } + + return entity; + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a lease offer with all related entities. + /// + public async Task GetLeaseOfferWithRelationsAsync(Guid leaseOfferId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .FirstOrDefaultAsync(lo => lo.Id == leaseOfferId + && !lo.IsDeleted + && lo.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseOfferWithRelations"); + throw; + } + } + + /// + /// Gets all lease offers with related entities. + /// + public async Task> GetLeaseOffersWithRelationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .Where(lo => !lo.IsDeleted && lo.OrganizationId == organizationId) + .OrderByDescending(lo => lo.OfferedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseOffersWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets lease offer by rental application ID. + /// + public async Task GetLeaseOfferByApplicationIdAsync(Guid applicationId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .FirstOrDefaultAsync(lo => lo.RentalApplicationId == applicationId + && !lo.IsDeleted + && lo.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseOfferByApplicationId"); + throw; + } + } + + /// + /// Gets lease offers by property ID. + /// + public async Task> GetLeaseOffersByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .Where(lo => lo.PropertyId == propertyId + && !lo.IsDeleted + && lo.OrganizationId == organizationId) + .OrderByDescending(lo => lo.OfferedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseOffersByPropertyId"); + throw; + } + } + + /// + /// Gets lease offers by status. + /// + public async Task> GetLeaseOffersByStatusAsync(string status) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .Where(lo => lo.Status == status + && !lo.IsDeleted + && lo.OrganizationId == organizationId) + .OrderByDescending(lo => lo.OfferedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetLeaseOffersByStatus"); + throw; + } + } + + /// + /// Gets active (pending) lease offers. + /// + public async Task> GetActiveLeaseOffersAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.LeaseOffers + .Include(lo => lo.RentalApplication) + .Include(lo => lo.Property) + .Include(lo => lo.ProspectiveTenant) + .Where(lo => lo.Status == "Pending" + && !lo.IsDeleted + && lo.OrganizationId == organizationId + && lo.ExpiresOn > DateTime.UtcNow) + .OrderByDescending(lo => lo.OfferedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetActiveLeaseOffers"); + throw; + } + } + + /// + /// Updates lease offer status. + /// + public async Task UpdateLeaseOfferStatusAsync(Guid leaseOfferId, string newStatus, string? responseNotes = null) + { + try + { + var leaseOffer = await GetByIdAsync(leaseOfferId); + if (leaseOffer == null) + { + throw new InvalidOperationException($"Lease offer {leaseOfferId} not found"); + } + + leaseOffer.Status = newStatus; + leaseOffer.RespondedOn = DateTime.UtcNow; + + if (!string.IsNullOrWhiteSpace(responseNotes)) + { + leaseOffer.ResponseNotes = responseNotes; + } + + return await UpdateAsync(leaseOffer); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateLeaseOfferStatus"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/OrganizationService.cs b/Aquiis.SimpleStart/Application/Services/OrganizationService.cs index afbfa69..21788e7 100644 --- a/Aquiis.SimpleStart/Application/Services/OrganizationService.cs +++ b/Aquiis.SimpleStart/Application/Services/OrganizationService.cs @@ -446,6 +446,50 @@ public async Task> GetActiveUserAssignmentsAsync() .ToListAsync(); } + /// + /// Gets organization settings by organization ID (for scheduled tasks). + /// + public async Task GetOrganizationSettingsByOrgIdAsync(Guid organizationId) + { + return await _dbContext.OrganizationSettings + .Where(s => !s.IsDeleted && s.OrganizationId == organizationId) + .FirstOrDefaultAsync(); + } + + /// + /// Gets the organization settings for the current user's active organization. + /// If no settings exist, creates default settings. + /// + public async Task GetOrganizationSettingsAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + if (!organizationId.HasValue || organizationId == Guid.Empty) + throw new InvalidOperationException("Organization ID not found for current user"); + + return await GetOrganizationSettingsByOrgIdAsync(organizationId.Value); + } + + /// + /// Updates the organization settings for the current user's organization. + /// + public async Task UpdateOrganizationSettingsAsync(OrganizationSettings settings) + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + if (!organizationId.HasValue || organizationId == Guid.Empty) + throw new InvalidOperationException("Organization ID not found for current user"); + + if (settings.OrganizationId != organizationId.Value) + throw new InvalidOperationException("Cannot update settings for a different organization"); + + var userId = await _userContext.GetUserIdAsync(); + settings.LastModifiedOn = DateTime.UtcNow; + settings.LastModifiedBy = string.IsNullOrEmpty(userId) ? string.Empty : userId; + + _dbContext.OrganizationSettings.Update(settings); + await _dbContext.SaveChangesAsync(); + return true; + } + #endregion } } diff --git a/Aquiis.SimpleStart/Application/Services/PaymentService.cs b/Aquiis.SimpleStart/Application/Services/PaymentService.cs index 36da8b1..47219c6 100644 --- a/Aquiis.SimpleStart/Application/Services/PaymentService.cs +++ b/Aquiis.SimpleStart/Application/Services/PaymentService.cs @@ -85,7 +85,8 @@ protected override async Task ValidateEntityAsync(Payment entity) } // Validate payment method - var validMethods = new[] { "Cash", "Check", "CreditCard", "BankTransfer", "ACH", "Wire", "MoneyOrder", "Other" }; + var validMethods = ApplicationConstants.PaymentMethods.AllPaymentMethods; + if (!string.IsNullOrWhiteSpace(entity.PaymentMethod) && !validMethods.Contains(entity.PaymentMethod)) { errors.Add($"Payment method must be one of: {string.Join(", ", validMethods)}"); diff --git a/Aquiis.SimpleStart/Application/Services/PropertyService.cs b/Aquiis.SimpleStart/Application/Services/PropertyService.cs index 51f8622..e96901f 100644 --- a/Aquiis.SimpleStart/Application/Services/PropertyService.cs +++ b/Aquiis.SimpleStart/Application/Services/PropertyService.cs @@ -327,6 +327,56 @@ private async Task CreateRoutineInspectionCalendarEventAsync(Property property) await _calendarEventService.CreateCustomEventAsync(calendarEvent); } + /// + /// Gets properties with overdue routine inspections. + /// + public async Task> GetPropertiesWithOverdueInspectionsAsync() + { + try + { + var organizationId = await _userContext.GetOrganizationIdAsync(); + + return await _context.Properties + .Where(p => p.OrganizationId == organizationId && + !p.IsDeleted && + p.NextRoutineInspectionDueDate.HasValue && + p.NextRoutineInspectionDueDate.Value < DateTime.Today) + .OrderBy(p => p.NextRoutineInspectionDueDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPropertiesWithOverdueInspections"); + throw; + } + } + + /// + /// Gets properties with inspections due within specified days. + /// + public async Task> GetPropertiesWithInspectionsDueSoonAsync(int daysAhead = 30) + { + try + { + var organizationId = await _userContext.GetOrganizationIdAsync(); + var dueDate = DateTime.Today.AddDays(daysAhead); + + return await _context.Properties + .Where(p => p.OrganizationId == organizationId && + !p.IsDeleted && + p.NextRoutineInspectionDueDate.HasValue && + p.NextRoutineInspectionDueDate.Value >= DateTime.Today && + p.NextRoutineInspectionDueDate.Value <= dueDate) + .OrderBy(p => p.NextRoutineInspectionDueDate) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPropertiesWithInspectionsDueSoon"); + throw; + } + } + #endregion } } diff --git a/Aquiis.SimpleStart/Application/Services/ProspectiveTenantService.cs b/Aquiis.SimpleStart/Application/Services/ProspectiveTenantService.cs new file mode 100644 index 0000000..094ae52 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/ProspectiveTenantService.cs @@ -0,0 +1,218 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing ProspectiveTenant entities. + /// Inherits common CRUD operations from BaseService and adds prospective tenant-specific business logic. + /// + public class ProspectiveTenantService : BaseService + { + public ProspectiveTenantService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with ProspectiveTenant-Specific Logic + + /// + /// Validates a prospective tenant entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(ProspectiveTenant entity) + { + var errors = new List(); + + // Required field validation + if (string.IsNullOrWhiteSpace(entity.FirstName)) + { + errors.Add("FirstName is required"); + } + + if (string.IsNullOrWhiteSpace(entity.LastName)) + { + errors.Add("LastName is required"); + } + + if (string.IsNullOrWhiteSpace(entity.Email) && string.IsNullOrWhiteSpace(entity.Phone)) + { + errors.Add("Either Email or Phone is required"); + } + + // Email format validation + if (!string.IsNullOrWhiteSpace(entity.Email) && !entity.Email.Contains("@")) + { + errors.Add("Email must be a valid email address"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + /// + /// Sets default values for create operations. + /// + protected override async Task SetCreateDefaultsAsync(ProspectiveTenant entity) + { + entity = await base.SetCreateDefaultsAsync(entity); + + // Set default status if not already set + if (string.IsNullOrWhiteSpace(entity.Status)) + { + entity.Status = ApplicationConstants.ProspectiveStatuses.Lead; + } + + // Set first contacted date if not already set + if (entity.FirstContactedOn == DateTime.MinValue) + { + entity.FirstContactedOn = DateTime.UtcNow; + } + + return entity; + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a prospective tenant with all related entities. + /// + public async Task GetProspectiveTenantWithRelationsAsync(Guid prospectiveTenantId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ProspectiveTenants + .Include(pt => pt.InterestedProperty) + .Include(pt => pt.Tours) + .Include(pt => pt.Applications) + .FirstOrDefaultAsync(pt => pt.Id == prospectiveTenantId + && !pt.IsDeleted + && pt.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetProspectiveTenantWithRelations"); + throw; + } + } + + /// + /// Gets all prospective tenants with related entities. + /// + public async Task> GetProspectiveTenantsWithRelationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ProspectiveTenants + .Include(pt => pt.InterestedProperty) + .Include(pt => pt.Tours) + .Include(pt => pt.Applications) + .Where(pt => !pt.IsDeleted && pt.OrganizationId == organizationId) + .OrderByDescending(pt => pt.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetProspectiveTenantsWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets prospective tenants by status. + /// + public async Task> GetProspectivesByStatusAsync(string status) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ProspectiveTenants + .Where(pt => pt.Status == status + && !pt.IsDeleted + && pt.OrganizationId == organizationId) + .Include(pt => pt.InterestedProperty) + .OrderByDescending(pt => pt.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetProspectivesByStatus"); + throw; + } + } + + /// + /// Gets prospective tenants interested in a specific property. + /// + public async Task> GetProspectivesByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ProspectiveTenants + .Where(pt => pt.InterestedPropertyId == propertyId + && !pt.IsDeleted + && pt.OrganizationId == organizationId) + .Include(pt => pt.InterestedProperty) + .Include(pt => pt.Tours) + .OrderByDescending(pt => pt.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetProspectivesByPropertyId"); + throw; + } + } + + /// + /// Updates a prospective tenant's status. + /// + public async Task UpdateStatusAsync(Guid prospectiveTenantId, string newStatus) + { + try + { + var prospect = await GetByIdAsync(prospectiveTenantId); + if (prospect == null) + { + throw new InvalidOperationException($"Prospective tenant {prospectiveTenantId} not found"); + } + + prospect.Status = newStatus; + return await UpdateAsync(prospect); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateStatus"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/RentalApplicationService.cs b/Aquiis.SimpleStart/Application/Services/RentalApplicationService.cs new file mode 100644 index 0000000..e37007e --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/RentalApplicationService.cs @@ -0,0 +1,273 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing RentalApplication entities. + /// Inherits common CRUD operations from BaseService and adds rental application-specific business logic. + /// + public class RentalApplicationService : BaseService + { + public RentalApplicationService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with RentalApplication-Specific Logic + + /// + /// Validates a rental application entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(RentalApplication entity) + { + var errors = new List(); + + // Required field validation + if (entity.ProspectiveTenantId == Guid.Empty) + { + errors.Add("ProspectiveTenantId is required"); + } + + if (entity.PropertyId == Guid.Empty) + { + errors.Add("PropertyId is required"); + } + + if (entity.ApplicationFee < 0) + { + errors.Add("ApplicationFee cannot be negative"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + /// + /// Sets default values for create operations. + /// + protected override async Task SetCreateDefaultsAsync(RentalApplication entity) + { + entity = await base.SetCreateDefaultsAsync(entity); + + // Set default status if not already set + if (string.IsNullOrWhiteSpace(entity.Status)) + { + entity.Status = ApplicationConstants.ApplicationStatuses.Submitted; + } + + // Set applied date if not already set + if (entity.AppliedOn == DateTime.MinValue) + { + entity.AppliedOn = DateTime.UtcNow; + } + + // Get organization settings for fee and expiration defaults + var orgSettings = await _context.OrganizationSettings + .FirstOrDefaultAsync(s => s.OrganizationId == entity.OrganizationId && !s.IsDeleted); + + if (orgSettings != null) + { + // Set application fee if not already set and fees are enabled + if (orgSettings.ApplicationFeeEnabled && entity.ApplicationFee == 0) + { + entity.ApplicationFee = orgSettings.DefaultApplicationFee; + } + + // Set expiration date if not already set + if (entity.ExpiresOn == null) + { + entity.ExpiresOn = entity.AppliedOn.AddDays(orgSettings.ApplicationExpirationDays); + } + } + else + { + // Fallback defaults if no settings found + if (entity.ApplicationFee == 0) + { + entity.ApplicationFee = 50.00m; // Default fee + } + if (entity.ExpiresOn == null) + { + entity.ExpiresOn = entity.AppliedOn.AddDays(30); // Default 30 days + } + } + + return entity; + } + + /// + /// Post-create hook to update related entities. + /// + protected override async Task AfterCreateAsync(RentalApplication entity) + { + await base.AfterCreateAsync(entity); + + // Update property status to ApplicationPending + var property = await _context.Properties.FindAsync(entity.PropertyId); + if (property != null && property.Status == ApplicationConstants.PropertyStatuses.Available) + { + property.Status = ApplicationConstants.PropertyStatuses.ApplicationPending; + property.LastModifiedOn = DateTime.UtcNow; + property.LastModifiedBy = entity.CreatedBy; + await _context.SaveChangesAsync(); + } + + // Update ProspectiveTenant status + var prospective = await _context.ProspectiveTenants.FindAsync(entity.ProspectiveTenantId); + if (prospective != null) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.Applied; + prospective.LastModifiedOn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a rental application with all related entities. + /// + public async Task GetRentalApplicationWithRelationsAsync(Guid applicationId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.RentalApplications + .Include(ra => ra.ProspectiveTenant) + .Include(ra => ra.Property) + .Include(ra => ra.Screening) + .FirstOrDefaultAsync(ra => ra.Id == applicationId + && !ra.IsDeleted + && ra.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetRentalApplicationWithRelations"); + throw; + } + } + + /// + /// Gets all rental applications with related entities. + /// + public async Task> GetRentalApplicationsWithRelationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.RentalApplications + .Include(ra => ra.ProspectiveTenant) + .Include(ra => ra.Property) + .Include(ra => ra.Screening) + .Where(ra => !ra.IsDeleted && ra.OrganizationId == organizationId) + .OrderByDescending(ra => ra.AppliedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetRentalApplicationsWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets rental application by prospective tenant ID. + /// + public async Task GetApplicationByProspectiveIdAsync(Guid prospectiveTenantId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.RentalApplications + .Include(ra => ra.Property) + .Include(ra => ra.Screening) + .FirstOrDefaultAsync(ra => ra.ProspectiveTenantId == prospectiveTenantId + && !ra.IsDeleted + && ra.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetApplicationByProspectiveId"); + throw; + } + } + + /// + /// Gets pending rental applications. + /// + public async Task> GetPendingApplicationsAsync() + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.RentalApplications + .Include(ra => ra.ProspectiveTenant) + .Include(ra => ra.Property) + .Include(ra => ra.Screening) + .Where(ra => !ra.IsDeleted + && ra.OrganizationId == organizationId + && (ra.Status == ApplicationConstants.ApplicationStatuses.Submitted + || ra.Status == ApplicationConstants.ApplicationStatuses.Screening)) + .OrderByDescending(ra => ra.AppliedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetPendingApplications"); + throw; + } + } + + /// + /// Gets rental applications by property ID. + /// + public async Task> GetApplicationsByPropertyIdAsync(Guid propertyId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.RentalApplications + .Include(ra => ra.ProspectiveTenant) + .Include(ra => ra.Screening) + .Where(ra => ra.PropertyId == propertyId + && !ra.IsDeleted + && ra.OrganizationId == organizationId) + .OrderByDescending(ra => ra.AppliedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetApplicationsByPropertyId"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/ScheduledTaskService.cs b/Aquiis.SimpleStart/Application/Services/ScheduledTaskService.cs index 536782d..3aa7ef8 100644 --- a/Aquiis.SimpleStart/Application/Services/ScheduledTaskService.cs +++ b/Aquiis.SimpleStart/Application/Services/ScheduledTaskService.cs @@ -77,7 +77,7 @@ private async Task DoWork(CancellationToken stoppingToken) { var dbContext = scope.ServiceProvider.GetRequiredService(); var toastService = scope.ServiceProvider.GetRequiredService(); - var propertyManagementService = scope.ServiceProvider.GetRequiredService(); + var organizationService = scope.ServiceProvider.GetRequiredService(); // Get all distinct organization IDs from OrganizationSettings var organizations = await dbContext.OrganizationSettings @@ -89,7 +89,7 @@ private async Task DoWork(CancellationToken stoppingToken) foreach (var organizationId in organizations) { // Get settings for this organization - var settings = await propertyManagementService.GetOrganizationSettingsByOrgIdAsync(organizationId); + var settings = await organizationService.GetOrganizationSettingsByOrgIdAsync(organizationId); if (settings == null) { @@ -392,12 +392,13 @@ private async Task ExecuteDailyTasks() try { using var scope = _serviceProvider.CreateScope(); - var propertyManagementService = scope.ServiceProvider.GetRequiredService(); + var paymentService = scope.ServiceProvider.GetRequiredService(); + var propertyService = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); // Calculate daily payment totals var today = DateTime.Today; - var todayPayments = await propertyManagementService.GetPaymentsAsync(); + var todayPayments = await paymentService.GetAllAsync(); var dailyTotal = todayPayments .Where(p => p.PaidOn.Date == today && !p.IsDeleted) .Sum(p => p.Amount); @@ -407,7 +408,7 @@ private async Task ExecuteDailyTasks() dailyTotal); // Check for overdue routine inspections - var overdueInspections = await propertyManagementService.GetPropertiesWithOverdueInspectionsAsync(); + var overdueInspections = await propertyService.GetPropertiesWithOverdueInspectionsAsync(); if (overdueInspections.Any()) { _logger.LogWarning("{Count} propert(ies) have overdue routine inspections", @@ -424,7 +425,7 @@ private async Task ExecuteDailyTasks() } // Check for inspections due soon (within 30 days) - var dueSoonInspections = await propertyManagementService.GetPropertiesWithInspectionsDueSoonAsync(30); + var dueSoonInspections = await propertyService.GetPropertiesWithInspectionsDueSoonAsync(30); if (dueSoonInspections.Any()) { _logger.LogInformation("{Count} propert(ies) have routine inspections due within 30 days", @@ -473,7 +474,8 @@ private async Task ExecuteHourlyTasks() try { using var scope = _serviceProvider.CreateScope(); - var propertyManagementService = scope.ServiceProvider.GetRequiredService(); + var tourService = scope.ServiceProvider.GetRequiredService(); + var leaseService = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); // Get all organizations @@ -505,7 +507,7 @@ private async Task ExecuteHourlyTasks() foreach (var tour in noShowTours) { - await propertyManagementService.MarkTourAsNoShowAsync(tour.Id); + await tourService.MarkTourAsNoShowAsync(tour.Id); totalMarkedNoShow++; _logger.LogInformation( @@ -527,7 +529,7 @@ private async Task ExecuteHourlyTasks() if (!string.IsNullOrEmpty(userId)) { - var upcomingLeases = await propertyManagementService.GetLeasesAsync(); + var upcomingLeases = await leaseService.GetAllAsync(); var expiringIn30Days = upcomingLeases .Where(l => l.EndDate >= DateTime.Today && l.EndDate <= DateTime.Today.AddDays(30) && diff --git a/Aquiis.SimpleStart/Application/Services/ScreeningService.cs b/Aquiis.SimpleStart/Application/Services/ScreeningService.cs new file mode 100644 index 0000000..409fb66 --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/ScreeningService.cs @@ -0,0 +1,237 @@ +using System.ComponentModel.DataAnnotations; +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing ApplicationScreening entities. + /// Inherits common CRUD operations from BaseService and adds screening-specific business logic. + /// + public class ScreeningService : BaseService + { + public ScreeningService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings) + : base(context, logger, userContext, settings) + { + } + + #region Overrides with Screening-Specific Logic + + /// + /// Validates an application screening entity before create/update operations. + /// + protected override async Task ValidateEntityAsync(ApplicationScreening entity) + { + var errors = new List(); + + // Required field validation + if (entity.RentalApplicationId == Guid.Empty) + { + errors.Add("RentalApplicationId is required"); + } + + if (errors.Any()) + { + throw new ValidationException(string.Join("; ", errors)); + } + + await base.ValidateEntityAsync(entity); + } + + /// + /// Sets default values for create operations. + /// + protected override async Task SetCreateDefaultsAsync(ApplicationScreening entity) + { + entity = await base.SetCreateDefaultsAsync(entity); + + // Set default overall result if not already set + if (string.IsNullOrWhiteSpace(entity.OverallResult)) + { + entity.OverallResult = ApplicationConstants.ScreeningResults.Pending; + } + + return entity; + } + + /// + /// Post-create hook to update related application and prospective tenant status. + /// + protected override async Task AfterCreateAsync(ApplicationScreening entity) + { + await base.AfterCreateAsync(entity); + + // Update application and prospective tenant status + var application = await _context.RentalApplications.FindAsync(entity.RentalApplicationId); + if (application != null) + { + application.Status = ApplicationConstants.ApplicationStatuses.Screening; + application.LastModifiedOn = DateTime.UtcNow; + + var prospective = await _context.ProspectiveTenants.FindAsync(application.ProspectiveTenantId); + if (prospective != null) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.Screening; + prospective.LastModifiedOn = DateTime.UtcNow; + } + + await _context.SaveChangesAsync(); + } + } + + #endregion + + #region Retrieval Methods + + /// + /// Gets a screening with related rental application. + /// + public async Task GetScreeningWithRelationsAsync(Guid screeningId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ApplicationScreenings + .Include(asc => asc.RentalApplication) + .ThenInclude(ra => ra!.ProspectiveTenant) + .Include(asc => asc.RentalApplication) + .ThenInclude(ra => ra!.Property) + .FirstOrDefaultAsync(asc => asc.Id == screeningId + && !asc.IsDeleted + && asc.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetScreeningWithRelations"); + throw; + } + } + + #endregion + + #region Business Logic Methods + + /// + /// Gets screening by rental application ID. + /// + public async Task GetScreeningByApplicationIdAsync(Guid rentalApplicationId) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ApplicationScreenings + .Include(asc => asc.RentalApplication) + .FirstOrDefaultAsync(asc => asc.RentalApplicationId == rentalApplicationId + && !asc.IsDeleted + && asc.OrganizationId == organizationId); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetScreeningByApplicationId"); + throw; + } + } + + /// + /// Gets screenings by result status. + /// + public async Task> GetScreeningsByResultAsync(string result) + { + try + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _context.ApplicationScreenings + .Include(asc => asc.RentalApplication) + .ThenInclude(ra => ra!.ProspectiveTenant) + .Where(asc => asc.OverallResult == result + && !asc.IsDeleted + && asc.OrganizationId == organizationId) + .OrderByDescending(asc => asc.CreatedOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetScreeningsByResult"); + throw; + } + } + + /// + /// Updates screening result and automatically updates application status. + /// + public async Task UpdateScreeningResultAsync(Guid screeningId, string result, string? notes = null) + { + try + { + var screening = await GetByIdAsync(screeningId); + if (screening == null) + { + throw new InvalidOperationException($"Screening {screeningId} not found"); + } + + screening.OverallResult = result; + if (!string.IsNullOrWhiteSpace(notes)) + { + screening.ResultNotes = notes; + } + + await UpdateAsync(screening); + + // Update application status based on screening result + var application = await _context.RentalApplications.FindAsync(screening.RentalApplicationId); + if (application != null) + { + if (result == ApplicationConstants.ScreeningResults.Passed || result == ApplicationConstants.ScreeningResults.ConditionalPass) + { + application.Status = ApplicationConstants.ApplicationStatuses.Approved; + } + else if (result == ApplicationConstants.ScreeningResults.Failed) + { + application.Status = ApplicationConstants.ApplicationStatuses.Denied; + } + + application.LastModifiedOn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + // Update prospective tenant status + var prospective = await _context.ProspectiveTenants.FindAsync(application.ProspectiveTenantId); + if (prospective != null) + { + if (result == ApplicationConstants.ScreeningResults.Passed || result == ApplicationConstants.ScreeningResults.ConditionalPass) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.Approved; + } + else if (result == ApplicationConstants.ScreeningResults.Failed) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.Denied; + } + + prospective.LastModifiedOn = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } + + return screening; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "UpdateScreeningResult"); + throw; + } + } + + #endregion + } +} diff --git a/Aquiis.SimpleStart/Application/Services/TourService.cs b/Aquiis.SimpleStart/Application/Services/TourService.cs new file mode 100644 index 0000000..d126cfb --- /dev/null +++ b/Aquiis.SimpleStart/Application/Services/TourService.cs @@ -0,0 +1,490 @@ +using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Entities; +using Aquiis.SimpleStart.Core.Interfaces; +using Aquiis.SimpleStart.Core.Services; +using Aquiis.SimpleStart.Infrastructure.Data; +using Aquiis.SimpleStart.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Aquiis.SimpleStart.Application.Services +{ + /// + /// Service for managing property tours with business logic for scheduling, + /// prospect tracking, and checklist integration. + /// + public class TourService : BaseService + { + private readonly ICalendarEventService _calendarEventService; + private readonly ChecklistService _checklistService; + + public TourService( + ApplicationDbContext context, + ILogger logger, + UserContextService userContext, + IOptions settings, + ICalendarEventService calendarEventService, + ChecklistService checklistService) + : base(context, logger, userContext, settings) + { + _calendarEventService = calendarEventService; + _checklistService = checklistService; + } + + #region Helper Methods + + protected async Task GetUserIdAsync() + { + var userId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(userId)) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + return userId; + } + + protected async Task GetActiveOrganizationIdAsync() + { + var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + if (!organizationId.HasValue) + { + throw new UnauthorizedAccessException("No active organization."); + } + return organizationId.Value; + } + + #endregion + + /// + /// Validates tour business rules. + /// + protected override async Task ValidateEntityAsync(Tour entity) + { + var errors = new List(); + + // Required fields + if (entity.ProspectiveTenantId == Guid.Empty) + { + errors.Add("Prospective tenant is required"); + } + + if (entity.PropertyId == Guid.Empty) + { + errors.Add("Property is required"); + } + + if (entity.ScheduledOn == default) + { + errors.Add("Scheduled date/time is required"); + } + + if (entity.DurationMinutes <= 0) + { + errors.Add("Duration must be greater than 0"); + } + + if (errors.Any()) + { + throw new InvalidOperationException(string.Join("; ", errors)); + } + + await Task.CompletedTask; + } + + /// + /// Gets all tours for the active organization. + /// + public override async Task> GetAllAsync() + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Tours + .Include(t => t.ProspectiveTenant) + .Include(t => t.Property) + .Include(t => t.Checklist) + .Where(t => !t.IsDeleted && t.OrganizationId == organizationId) + .OrderBy(t => t.ScheduledOn) + .ToListAsync(); + } + + /// + /// Gets tours by prospective tenant ID. + /// + public async Task> GetByProspectiveIdAsync(Guid prospectiveTenantId) + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Tours + .Include(t => t.ProspectiveTenant) + .Include(t => t.Property) + .Include(t => t.Checklist) + .Where(t => t.ProspectiveTenantId == prospectiveTenantId && + !t.IsDeleted && + t.OrganizationId == organizationId) + .OrderBy(t => t.ScheduledOn) + .ToListAsync(); + } + + /// + /// Gets a single tour by ID with related data. + /// + public override async Task GetByIdAsync(Guid id) + { + var organizationId = await GetActiveOrganizationIdAsync(); + + return await _context.Tours + .Include(t => t.ProspectiveTenant) + .Include(t => t.Property) + .Include(t => t.Checklist) + .FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted && t.OrganizationId == organizationId); + } + + /// + /// Creates a new tour with optional checklist from template. + /// + public async Task CreateAsync(Tour tour, Guid? checklistTemplateId = null) + { + await ValidateEntityAsync(tour); + + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + tour.Id = Guid.NewGuid(); + tour.OrganizationId = organizationId; + tour.CreatedBy = userId; + tour.CreatedOn = DateTime.UtcNow; + tour.Status = ApplicationConstants.TourStatuses.Scheduled; + + // Get prospect information for checklist + var prospective = await _context.ProspectiveTenants + .Include(p => p.InterestedProperty) + .FirstOrDefaultAsync(p => p.Id == tour.ProspectiveTenantId); + + // Create checklist if template specified + if (checklistTemplateId.HasValue || prospective != null) + { + await CreateTourChecklistAsync(tour, prospective, checklistTemplateId); + } + + await _context.Tours.AddAsync(tour); + await _context.SaveChangesAsync(); + + // Create calendar event for the tour + await _calendarEventService.CreateOrUpdateEventAsync(tour); + + // Update prospective tenant status if needed + if (prospective != null && prospective.Status == ApplicationConstants.ProspectiveStatuses.Lead) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.TourScheduled; + prospective.LastModifiedOn = DateTime.UtcNow; + prospective.LastModifiedBy = userId; + await _context.SaveChangesAsync(); + } + + _logger.LogInformation("Created tour {TourId} for prospect {ProspectId}", + tour.Id, tour.ProspectiveTenantId); + + return tour; + } + + /// + /// Creates a tour using the base CreateAsync (without template parameter). + /// + public override async Task CreateAsync(Tour tour) + { + return await CreateAsync(tour, checklistTemplateId: null); + } + + /// + /// Updates an existing tour. + /// + public override async Task UpdateAsync(Tour tour) + { + await ValidateEntityAsync(tour); + + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + // Security: Verify tour belongs to active organization + var existing = await _context.Tours + .FirstOrDefaultAsync(t => t.Id == tour.Id && t.OrganizationId == organizationId); + + if (existing == null) + { + throw new UnauthorizedAccessException($"Tour {tour.Id} not found in active organization."); + } + + // Set tracking fields + tour.LastModifiedBy = userId; + tour.LastModifiedOn = DateTime.UtcNow; + tour.OrganizationId = organizationId; // Prevent org hijacking + + _context.Entry(existing).CurrentValues.SetValues(tour); + await _context.SaveChangesAsync(); + + // Update calendar event + await _calendarEventService.CreateOrUpdateEventAsync(tour); + + _logger.LogInformation("Updated tour {TourId}", tour.Id); + + return tour; + } + + /// + /// Deletes a tour (soft delete). + /// + public override async Task DeleteAsync(Guid id) + { + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + var tour = await _context.Tours + .FirstOrDefaultAsync(t => t.Id == id && t.OrganizationId == organizationId); + + if (tour == null) + { + throw new KeyNotFoundException($"Tour {id} not found."); + } + + tour.IsDeleted = true; + tour.LastModifiedBy = userId; + tour.LastModifiedOn = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + // TODO: Delete associated calendar event when interface method is available + // await _calendarEventService.DeleteEventBySourceAsync(id, nameof(Tour)); + + _logger.LogInformation("Deleted tour {TourId}", id); + + return true; + } + + /// + /// Completes a tour with feedback and interest level. + /// + public async Task CompleteTourAsync(Guid tourId, string? feedback = null, string? interestLevel = null) + { + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + var tour = await GetByIdAsync(tourId); + if (tour == null) return false; + + // Update tour status and feedback + tour.Status = ApplicationConstants.TourStatuses.Completed; + tour.Feedback = feedback; + tour.InterestLevel = interestLevel; + tour.ConductedBy = userId; + tour.LastModifiedOn = DateTime.UtcNow; + tour.LastModifiedBy = userId; + + await _context.SaveChangesAsync(); + + // Update calendar event + await _calendarEventService.CreateOrUpdateEventAsync(tour); + + // Update prospective tenant status if highly interested + if (interestLevel == ApplicationConstants.TourInterestLevels.VeryInterested) + { + var prospect = await _context.ProspectiveTenants + .FirstOrDefaultAsync(p => p.Id == tour.ProspectiveTenantId); + + if (prospect != null && prospect.Status == ApplicationConstants.ProspectiveStatuses.TourScheduled) + { + prospect.Status = ApplicationConstants.ProspectiveStatuses.Applied; + prospect.LastModifiedOn = DateTime.UtcNow; + prospect.LastModifiedBy = userId; + await _context.SaveChangesAsync(); + } + } + + _logger.LogInformation("Completed tour {TourId} with interest level {InterestLevel}", + tourId, interestLevel); + + return true; + } + + /// + /// Creates a checklist for a tour from a template. + /// + private async Task CreateTourChecklistAsync(Tour tour, ProspectiveTenant? prospective, Guid? templateId) + { + var organizationId = await GetActiveOrganizationIdAsync(); + + // Find the specified template, or fall back to default "Property Tour" template + ChecklistTemplate? tourTemplate = null; + + if (templateId.HasValue) + { + tourTemplate = await _context.ChecklistTemplates + .FirstOrDefaultAsync(t => t.Id == templateId.Value && + (t.OrganizationId == organizationId || t.IsSystemTemplate) && + !t.IsDeleted); + } + + // Fall back to default "Property Tour" template if not specified or not found + if (tourTemplate == null) + { + tourTemplate = await _context.ChecklistTemplates + .FirstOrDefaultAsync(t => t.Name == "Property Tour" && + (t.OrganizationId == organizationId || t.IsSystemTemplate) && + !t.IsDeleted); + } + + if (tourTemplate != null && prospective != null) + { + // Create checklist from template + var checklist = await _checklistService.CreateChecklistFromTemplateAsync(tourTemplate.Id); + + // Customize checklist with prospect information + checklist.Name = $"Property Tour - {prospective.FullName}"; + checklist.PropertyId = tour.PropertyId; + checklist.GeneralNotes = $"Prospect: {prospective.FullName}\n" + + $"Email: {prospective.Email}\n" + + $"Phone: {prospective.Phone}\n" + + $"Scheduled: {tour.ScheduledOn:MMM dd, yyyy h:mm tt}"; + + // Link tour to checklist + tour.ChecklistId = checklist.Id; + } + } + + /// + /// Marks a tour as no-show and updates the associated calendar event. + /// + public async Task MarkTourAsNoShowAsync(Guid tourId) + { + try + { + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + + var tour = await GetByIdAsync(tourId); + if (tour == null) return false; + + if (tour.OrganizationId != organizationId) + { + throw new UnauthorizedAccessException("User is not authorized to update this tour."); + } + + // Update tour status to NoShow + tour.Status = "NoShow"; + tour.LastModifiedOn = DateTime.UtcNow; + tour.LastModifiedBy = userId; + + // Update calendar event status + if (tour.CalendarEventId.HasValue) + { + var calendarEvent = await _context.CalendarEvents + .FirstOrDefaultAsync(e => e.Id == tour.CalendarEventId.Value); + if (calendarEvent != null) + { + calendarEvent.Status = "NoShow"; + calendarEvent.LastModifiedBy = userId; + calendarEvent.LastModifiedOn = DateTime.UtcNow; + } + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Tour {TourId} marked as no-show by user {UserId}", tourId, userId); + return true; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "MarkTourAsNoShow"); + throw; + } + } + + /// + /// Cancels a tour and updates related prospect status. + /// + public async Task CancelTourAsync(Guid tourId) + { + try + { + var userId = await GetUserIdAsync(); + var organizationId = await GetActiveOrganizationIdAsync(); + var tour = await GetByIdAsync(tourId); + + if (tour == null) + { + throw new InvalidOperationException("Tour not found."); + } + + if (tour.OrganizationId != organizationId) + { + throw new UnauthorizedAccessException("Unauthorized access to tour."); + } + + // Update tour status to cancelled + tour.Status = ApplicationConstants.TourStatuses.Cancelled; + tour.LastModifiedOn = DateTime.UtcNow; + tour.LastModifiedBy = userId; + await _context.SaveChangesAsync(); + + // Update calendar event status + await _calendarEventService.CreateOrUpdateEventAsync(tour); + + // Check if prospect has any other scheduled tours + var prospective = await _context.ProspectiveTenants.FindAsync(tour.ProspectiveTenantId); + if (prospective != null && prospective.Status == ApplicationConstants.ProspectiveStatuses.TourScheduled) + { + var hasOtherScheduledTours = await _context.Tours + .AnyAsync(s => s.ProspectiveTenantId == tour.ProspectiveTenantId + && s.Id != tourId + && !s.IsDeleted + && s.Status == ApplicationConstants.TourStatuses.Scheduled); + + // If no other scheduled tours, revert prospect status to Lead + if (!hasOtherScheduledTours) + { + prospective.Status = ApplicationConstants.ProspectiveStatuses.Lead; + prospective.LastModifiedOn = DateTime.UtcNow; + prospective.LastModifiedBy = userId; + await _context.SaveChangesAsync(); + } + } + + _logger.LogInformation("Tour {TourId} cancelled by user {UserId}", tourId, userId); + return true; + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "CancelTour"); + throw; + } + } + + /// + /// Gets upcoming tours within specified number of days. + /// + public async Task> GetUpcomingToursAsync(int days = 7) + { + try + { + var organizationId = await GetActiveOrganizationIdAsync(); + var startDate = DateTime.UtcNow; + var endDate = startDate.AddDays(days); + + return await _context.Tours + .Where(s => s.OrganizationId == organizationId + && !s.IsDeleted + && s.Status == ApplicationConstants.TourStatuses.Scheduled + && s.ScheduledOn >= startDate + && s.ScheduledOn <= endDate) + .Include(s => s.ProspectiveTenant) + .Include(s => s.Property) + .Include(s => s.Checklist) + .OrderBy(s => s.ScheduledOn) + .ToListAsync(); + } + catch (Exception ex) + { + await HandleExceptionAsync(ex, "GetUpcomingTours"); + throw; + } + } + } +} diff --git a/Aquiis.SimpleStart/Core/Services/BaseService.cs b/Aquiis.SimpleStart/Core/Services/BaseService.cs index ac7c5c9..b40504e 100644 --- a/Aquiis.SimpleStart/Core/Services/BaseService.cs +++ b/Aquiis.SimpleStart/Core/Services/BaseService.cs @@ -139,6 +139,15 @@ public virtual async Task CreateAsync(TEntity entity) var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + // Set organization ID BEFORE validation so validation rules can check it + if (HasOrganizationIdProperty(entity) && organizationId.HasValue) + { + SetOrganizationId(entity, organizationId.Value); + } + + // Call hook to set default values + entity = await SetCreateDefaultsAsync(entity); + // Validate entity await ValidateEntityAsync(entity); @@ -151,17 +160,14 @@ public virtual async Task CreateAsync(TEntity entity) // Set audit fields SetAuditFieldsForCreate(entity, userId); - // Set organization ID if property exists - if (HasOrganizationIdProperty(entity) && organizationId.HasValue) - { - SetOrganizationId(entity, organizationId.Value); - } - _dbSet.Add(entity); await _context.SaveChangesAsync(); _logger.LogInformation($"{typeof(TEntity).Name} created: {entity.Id} by user {userId}"); + // Call hook for post-create operations + await AfterCreateAsync(entity); + return entity; } catch (Exception ex) @@ -364,6 +370,25 @@ private void SetOrganizationId(TEntity entity, Guid organizationId) property?.SetValue(entity, organizationId); } + /// + /// Hook method called before creating entity to set default values. + /// Override in derived services to customize default behavior. + /// + protected virtual async Task SetCreateDefaultsAsync(TEntity entity) + { + await Task.CompletedTask; + return entity; + } + + /// + /// Hook method called after creating entity for post-creation operations. + /// Override in derived services to handle side effects like updating related entities. + /// + protected virtual async Task AfterCreateAsync(TEntity entity) + { + await Task.CompletedTask; + } + #endregion } } diff --git a/Aquiis.SimpleStart/Features/Administration/Settings/Pages/LateFeeSettings.razor b/Aquiis.SimpleStart/Features/Administration/Settings/Pages/LateFeeSettings.razor index eab015c..c2e1dc8 100644 --- a/Aquiis.SimpleStart/Features/Administration/Settings/Pages/LateFeeSettings.razor +++ b/Aquiis.SimpleStart/Features/Administration/Settings/Pages/LateFeeSettings.razor @@ -8,7 +8,7 @@ @using Microsoft.AspNetCore.Authorization @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject OrganizationService OrganizationService @inject UserContextService UserContext @inject ToastService ToastService @inject NavigationManager Navigation @@ -287,7 +287,7 @@ userRole = await UserContext.GetCurrentOrganizationRoleAsync() ?? "User"; canEdit = userRole != "User"; // User role is read-only - var settings = await PropertyService.GetOrganizationSettingsAsync(); + var settings = await OrganizationService.GetOrganizationSettingsAsync(); if (settings != null) { @@ -336,7 +336,7 @@ isSaving = true; // Get the existing entity - var settings = await PropertyService.GetOrganizationSettingsAsync(); + var settings = await OrganizationService.GetOrganizationSettingsAsync(); if (settings == null) { @@ -355,7 +355,7 @@ settings.PaymentReminderDaysBefore = viewModel.PaymentReminderDaysBefore; settings.TourNoShowGracePeriodHours = viewModel.TourNoShowGracePeriodHours; - await PropertyService.UpdateOrganizationSettingsAsync(settings); + await OrganizationService.UpdateOrganizationSettingsAsync(settings); ToastService.ShowSuccess("Late fee settings saved successfully!"); } diff --git a/Aquiis.SimpleStart/Features/Administration/Settings/Pages/OrganizationSettings.razor b/Aquiis.SimpleStart/Features/Administration/Settings/Pages/OrganizationSettings.razor index 72cf23f..e38dd36 100644 --- a/Aquiis.SimpleStart/Features/Administration/Settings/Pages/OrganizationSettings.razor +++ b/Aquiis.SimpleStart/Features/Administration/Settings/Pages/OrganizationSettings.razor @@ -9,7 +9,7 @@ @using Microsoft.AspNetCore.Authorization @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject OrganizationService OrganizationService @inject UserContextService UserContext @inject ToastService ToastService @inject NavigationManager Navigation @@ -394,7 +394,7 @@ private async Task LoadSettings() { - settings = await PropertyService.GetOrganizationSettingsAsync(); + settings = await OrganizationService.GetOrganizationSettingsAsync(); if (settings != null) { @@ -444,7 +444,7 @@ CreatedBy = "System" }; - await PropertyService.UpdateOrganizationSettingsAsync(settings); + await OrganizationService.UpdateOrganizationSettingsAsync(settings); successMessage = "Default settings created successfully!"; await LoadSettings(); } @@ -483,7 +483,7 @@ settings.TourNoShowGracePeriodHours = settingsModel.TourNoShowGracePeriodHours; settings.LastModifiedOn = DateTime.UtcNow; - await PropertyService.UpdateOrganizationSettingsAsync(settings); + await OrganizationService.UpdateOrganizationSettingsAsync(settings); successMessage = "Settings saved successfully!"; ToastService.ShowSuccess("Organization settings updated successfully."); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Applications.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Applications.razor index bd5d052..7abc25f 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Applications.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Applications.razor @@ -9,7 +9,7 @@ @inject NavigationManager Navigation @inject UserContextService UserContext -@inject PropertyManagementService PropertyService +@inject RentalApplicationService RentalApplicationService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -208,7 +208,7 @@ private async Task LoadApplications() { - applications = await PropertyService.GetAllRentalApplicationsAsync(); + applications = await RentalApplicationService.GetAllAsync(); pendingApplications = applications.Where(a => a.Status == ApplicationConstants.ApplicationStatuses.Submitted || diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/GenerateLeaseOffer.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/GenerateLeaseOffer.razor index 0379e99..4868ad7 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/GenerateLeaseOffer.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/GenerateLeaseOffer.razor @@ -9,7 +9,7 @@ @using Microsoft.AspNetCore.Authorization @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject RentalApplicationService RentalApplicationService @inject ApplicationWorkflowService WorkflowService @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthStateProvider @@ -292,7 +292,7 @@ private async Task LoadApplication() { - application = await PropertyService.GetRentalApplicationByIdAsync(ApplicationId); + application = await RentalApplicationService.GetRentalApplicationWithRelationsAsync(ApplicationId); if (application != null) { diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ProspectiveTenants.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ProspectiveTenants.razor index 0a90347..2b7b68d 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ProspectiveTenants.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ProspectiveTenants.razor @@ -11,7 +11,8 @@ @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService -@inject PropertyManagementService PropertyService +@inject ProspectiveTenantService ProspectiveTenantService +@inject PropertyService PropertyService @rendermode InteractiveServer @@ -293,12 +294,12 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue && organizationId != Guid.Empty) { - prospects = await PropertyService.GetAllProspectiveTenantsAsync(); + prospects = await ProspectiveTenantService.GetAllAsync(); // Load properties for dropdown var dbContextFactory = Navigation.ToAbsoluteUri("/").ToString(); // Get service // For now, we'll need to inject PropertyManagementService - var allProperties = await PropertyService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); properties = allProperties.Where(p => p.IsAvailable).ToList(); } else @@ -358,7 +359,7 @@ OrganizationId = organizationId.Value, }; - await PropertyService.CreateProspectiveTenantAsync(prospect); + await ProspectiveTenantService.CreateAsync(prospect); ToastService.ShowSuccess("Prospective tenant added successfully"); showAddForm = false; @@ -400,7 +401,7 @@ if (organizationId.HasValue && organizationId != Guid.Empty && !string.IsNullOrEmpty(userId)) { - await PropertyService.DeleteProspectiveTenantAsync(prospectId); + await ProspectiveTenantService.DeleteAsync(prospectId); ToastService.ShowSuccess("Prospect deleted successfully"); await LoadData(); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ReviewApplication.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ReviewApplication.razor index 932f9ef..f765a09 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ReviewApplication.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ReviewApplication.razor @@ -9,7 +9,9 @@ @using Microsoft.AspNetCore.Authorization @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject RentalApplicationService RentalApplicationService +@inject ScreeningService ScreeningService +@inject LeaseOfferService LeaseOfferService @inject ApplicationWorkflowService WorkflowService @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthStateProvider @@ -815,14 +817,14 @@ private async Task LoadApplication() { - application = await PropertyService.GetRentalApplicationByIdAsync(ApplicationId); + application = await RentalApplicationService.GetRentalApplicationWithRelationsAsync(ApplicationId); if (application != null) { - screening = await PropertyService.GetScreeningByApplicationIdAsync(ApplicationId); + screening = await ScreeningService.GetScreeningByApplicationIdAsync(ApplicationId); // Check if a lease offer already exists for this application - leaseOffer = await PropertyService.GetLeaseOfferByApplicationIdAsync(application.Id); + leaseOffer = await LeaseOfferService.GetLeaseOfferByApplicationIdAsync(application.Id); hasLeaseOffer = leaseOffer != null && !leaseOffer.IsDeleted; } } @@ -975,7 +977,7 @@ application.Status = ApplicationConstants.ApplicationStatuses.UnderReview; } - await PropertyService.UpdateRentalApplicationAsync(application); + await RentalApplicationService.UpdateAsync(application); var successMsg = $"Application fee of {application.ApplicationFee:C} collected via {feePaymentModel.PaymentMethod}"; if (!string.IsNullOrEmpty(feePaymentModel.ReferenceNumber)) @@ -1028,7 +1030,7 @@ // Update overall result await UpdateOverallScreeningResult(screening); - await PropertyService.UpdateScreeningAsync(screening); + await ScreeningService.UpdateAsync(screening); ToastService.ShowSuccess($"Background check marked as {(backgroundCheckDisposition ? "PASSED" : "FAILED")}"); showBackgroundCheckModal = false; @@ -1061,7 +1063,7 @@ // Update overall result await UpdateOverallScreeningResult(screening); - await PropertyService.UpdateScreeningAsync(screening); + await ScreeningService.UpdateAsync(screening); ToastService.ShowSuccess($"Credit check marked as {(creditCheckDisposition ? "PASSED" : "FAILED")}"); showCreditCheckModal = false; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ScheduleTour.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ScheduleTour.razor index 951c474..323fc05 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ScheduleTour.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ScheduleTour.razor @@ -8,7 +8,9 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] -@inject PropertyManagementService PropertyService +@inject ProspectiveTenantService ProspectiveTenantService +@inject PropertyService PropertyService +@inject TourService TourService @inject ChecklistService ChecklistService @inject UserContextService UserContext @inject NavigationManager Navigation @@ -204,12 +206,12 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - prospect = await PropertyService.GetProspectiveTenantByIdAsync(ProspectId); + prospect = await ProspectiveTenantService.GetByIdAsync(ProspectId); if (prospect != null) { // Load available properties (Available status only) - var allProperties = await PropertyService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); availableProperties = allProperties .Where(p => p.Status == ApplicationConstants.PropertyStatuses.Available) .ToList(); @@ -223,7 +225,7 @@ .ToList(); // Load existing tours for this prospect - upcomingTours = await PropertyService.GetToursByProspectiveIdAsync(ProspectId); + upcomingTours = await TourService.GetByProspectiveIdAsync(ProspectId); upcomingTours = upcomingTours .Where(s => s.ScheduledOn >= DateTime.Now && s.Status == ApplicationConstants.TourStatuses.Scheduled) .OrderBy(s => s.ScheduledOn) @@ -287,7 +289,7 @@ CreatedBy = userId }; - await PropertyService.CreateTourAsync(tour, newTour.ChecklistTemplateId); + await TourService.CreateAsync(tour, newTour.ChecklistTemplateId); ToastService.ShowSuccess("Tour scheduled successfully"); Navigation.NavigateTo("/PropertyManagement/Tours"); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/SubmitApplication.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/SubmitApplication.razor index 19e4362..ed1ecc8 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/SubmitApplication.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/SubmitApplication.razor @@ -10,7 +10,10 @@ @using Microsoft.AspNetCore.Authorization @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject ProspectiveTenantService ProspectiveTenantService +@inject RentalApplicationService RentalApplicationService +@inject PropertyService PropertyService +@inject OrganizationService OrganizationService @inject ApplicationWorkflowService WorkflowService @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthStateProvider @@ -401,15 +404,15 @@ private async Task LoadData() { // Load prospect - prospect = await PropertyService.GetProspectiveTenantByIdAsync(ProspectId); + prospect = await ProspectiveTenantService.GetByIdAsync(ProspectId); if (prospect == null) return; // Check if application already exists - existingApplication = await PropertyService.GetApplicationByProspectiveIdAsync(ProspectId); + existingApplication = await RentalApplicationService.GetApplicationByProspectiveIdAsync(ProspectId); // Load available properties - var allProperties = await PropertyService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); availableProperties = allProperties.Where(p => p.Status == ApplicationConstants.PropertyStatuses.Available || p.Status == ApplicationConstants.PropertyStatuses.ApplicationPending).ToList(); @@ -422,7 +425,7 @@ } // Load organization settings for application fee - var orgSettings = await PropertyService.GetOrganizationSettingsAsync(); + var orgSettings = await OrganizationService.GetOrganizationSettingsByOrgIdAsync(prospect.OrganizationId); if (orgSettings != null) { applicationFeeRequired = orgSettings.ApplicationFeeEnabled; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Tours.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Tours.razor index 96abbee..f572a36 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Tours.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/Tours.razor @@ -10,7 +10,7 @@ @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService -@inject PropertyManagementService PropertyService +@inject TourService TourService @rendermode InteractiveServer @@ -274,8 +274,8 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - allTours = await PropertyService.GetAllToursAsync(); - upcomingTours = await PropertyService.GetUpcomingToursAsync(7); + allTours = await TourService.GetAllAsync(); + upcomingTours = await TourService.GetUpcomingToursAsync(7); } } catch (Exception ex) @@ -310,7 +310,7 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { // Navigate to the property tour checklist to complete it @@ -340,7 +340,7 @@ var userId = await UserContext.GetUserIdAsync(); if (organizationId.HasValue) { - await PropertyService.CancelTourAsync(tourId); + await TourService.CancelTourAsync(tourId); ToastService.ShowSuccess("Tour cancelled"); await LoadData(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ToursCalendar.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ToursCalendar.razor index 14eebed..ae1d9e1 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ToursCalendar.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ToursCalendar.razor @@ -10,7 +10,7 @@ @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService -@inject PropertyManagementService PropertyService +@inject TourService TourService @rendermode InteractiveServer @@ -209,7 +209,7 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - allTours = await PropertyService.GetAllToursAsync(); + allTours = await TourService.GetAllAsync(); } } catch (Exception ex) @@ -569,7 +569,7 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { CloseModal(); @@ -599,7 +599,7 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - await PropertyService.CancelTourAsync(tourId); + await TourService.CancelTourAsync(tourId); ToastService.ShowSuccess("Tour cancelled successfully"); CloseModal(); await LoadTours(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ViewProspectiveTenant.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ViewProspectiveTenant.razor index 1f89a0b..6da4792 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ViewProspectiveTenant.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Applications/Pages/ViewProspectiveTenant.razor @@ -10,7 +10,10 @@ @using Microsoft.AspNetCore.Authorization @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] -@inject PropertyManagementService PropertyService +@inject ProspectiveTenantService ProspectiveTenantService +@inject TourService TourService +@inject RentalApplicationService RentalApplicationService +@inject PropertyService PropertyService @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService @@ -591,15 +594,15 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - prospect = await PropertyService.GetProspectiveTenantByIdAsync(ProspectId); + prospect = await ProspectiveTenantService.GetByIdAsync(ProspectId); if (prospect != null) { - tours = await PropertyService.GetToursByProspectiveIdAsync(ProspectId); - application = await PropertyService.GetApplicationByProspectiveIdAsync(ProspectId); + tours = await TourService.GetByProspectiveIdAsync(ProspectId); + application = await RentalApplicationService.GetApplicationByProspectiveIdAsync(ProspectId); // Load properties for edit dropdown - availableProperties = await PropertyService.GetPropertiesAsync(); + availableProperties = await PropertyService.GetAllAsync(); } } } @@ -662,7 +665,7 @@ prospect.InterestedPropertyId = Guid.TryParse(editModel.InterestedPropertyId, out var propId) && propId != Guid.Empty ? propId : null; prospect.DesiredMoveInDate = editModel.DesiredMoveInDate; - await PropertyService.UpdateProspectiveTenantAsync(prospect); + await ProspectiveTenantService.UpdateAsync(prospect); ToastService.ShowSuccess("Prospect updated successfully"); isEditing = false; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Calendar.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Calendar.razor index 48c6cdc..b768d8e 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Calendar.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Calendar.razor @@ -15,7 +15,11 @@ @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService -@inject PropertyManagementService PropertyService +@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService +@inject TourService TourService +@inject InspectionService InspectionService +@inject MaintenanceService MaintenanceService @rendermode InteractiveServer @@ -1250,7 +1254,7 @@ private async Task ShowTourDetailById(Guid tourId) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { selectedTour = tour; @@ -1263,7 +1267,7 @@ private async Task ShowInspectionDetailById(Guid inspectionId) { - var inspection = await PropertyService.GetInspectionByIdAsync(inspectionId); + var inspection = await InspectionService.GetByIdAsync(inspectionId); if (inspection != null) { selectedInspection = inspection; @@ -1276,7 +1280,7 @@ private async Task ShowMaintenanceRequestDetailById(Guid maintenanceId) { - var maintenanceRequest = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceId); + var maintenanceRequest = await MaintenanceService.GetByIdAsync(maintenanceId); if (maintenanceRequest != null) { selectedMaintenanceRequest = maintenanceRequest; @@ -1343,7 +1347,7 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { CloseModal(); @@ -1373,7 +1377,7 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - await PropertyService.CancelTourAsync(tourId); + await TourService.CancelTourAsync(tourId); ToastService.ShowSuccess("Tour cancelled successfully"); CloseModal(); await LoadEvents(); @@ -1394,7 +1398,7 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - await PropertyService.MarkTourAsNoShowAsync(tourId); + await TourService.MarkTourAsNoShowAsync(tourId); ToastService.ShowSuccess("Tour marked as No Show"); CloseModal(); await LoadEvents(); @@ -1415,13 +1419,13 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Work started on maintenance request"); // Reload the maintenance request to show updated status @@ -1445,12 +1449,13 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.Completed; + request.CompletedOn = request.CompletedOn ?? DateTime.UtcNow; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Maintenance request marked as complete"); // Reload the maintenance request to show updated status @@ -1474,12 +1479,12 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.Cancelled; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Maintenance request cancelled"); CloseModal(); await LoadEvents(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/CalendarListView.razor b/Aquiis.SimpleStart/Features/PropertyManagement/CalendarListView.razor index b5e66d7..662c4fe 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/CalendarListView.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/CalendarListView.razor @@ -9,11 +9,17 @@ @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @inject CalendarEventService CalendarEventService @inject CalendarSettingsService CalendarSettingsService -@inject PropertyManagementService PropertyService +@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject UserContextService UserContext @inject NavigationManager Navigation @inject ToastService ToastService +@inject TourService TourService +@inject InspectionService InspectionService +@inject MaintenanceService MaintenanceService +@inject LeaseService LeaseService + @rendermode InteractiveServer Calendar - List View @@ -601,7 +607,7 @@ private async Task ShowTourDetailById(Guid tourId) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { selectedTour = tour; @@ -614,7 +620,7 @@ private async Task ShowInspectionDetailById(Guid inspectionId) { - var inspection = await PropertyService.GetInspectionByIdAsync(inspectionId); + var inspection = await InspectionService.GetByIdAsync(inspectionId); if (inspection != null) { selectedInspection = inspection; @@ -627,7 +633,7 @@ private async Task ShowMaintenanceRequestDetailById(Guid maintenanceId) { - var maintenanceRequest = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceId); + var maintenanceRequest = await MaintenanceService.GetByIdAsync(maintenanceId); if (maintenanceRequest != null) { selectedMaintenanceRequest = maintenanceRequest; @@ -691,7 +697,7 @@ var organizationId = await UserContext.GetActiveOrganizationIdAsync(); if (organizationId.HasValue) { - var tour = await PropertyService.GetTourByIdAsync(tourId); + var tour = await TourService.GetByIdAsync(tourId); if (tour != null) { CloseModal(); @@ -721,7 +727,7 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - await PropertyService.CancelTourAsync(tourId); + await TourService.CancelTourAsync(tourId); ToastService.ShowSuccess("Tour cancelled successfully"); CloseModal(); await LoadEvents(); @@ -742,7 +748,7 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - await PropertyService.MarkTourAsNoShowAsync(tourId); + await TourService.MarkTourAsNoShowAsync(tourId); ToastService.ShowSuccess("Tour marked as No Show"); CloseModal(); await LoadEvents(); @@ -758,12 +764,12 @@ { try { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Work started on maintenance request"); await ShowMaintenanceRequestDetailById(maintenanceRequestId); @@ -785,12 +791,12 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.Completed; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Maintenance request marked as complete"); await ShowMaintenanceRequestDetailById(maintenanceRequestId); @@ -813,12 +819,12 @@ if (organizationId.HasValue && !string.IsNullOrEmpty(userId)) { - var request = await PropertyService.GetMaintenanceRequestByIdAsync(maintenanceRequestId); + var request = await MaintenanceService.GetByIdAsync(maintenanceRequestId); if (request != null) { request.Status = ApplicationConstants.MaintenanceRequestStatuses.Cancelled; - await PropertyService.UpdateMaintenanceRequestAsync(request); + await MaintenanceService.UpdateAsync(request); ToastService.ShowSuccess("Maintenance request cancelled"); CloseModal(); await LoadEvents(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor index 7aa0369..b110e95 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor @@ -8,7 +8,8 @@ @using Microsoft.AspNetCore.Components @inject ChecklistService ChecklistService -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService +@inject LeaseService LeaseService @inject UserContextService UserContext @inject ToastService ToastService @inject NavigationManager NavigationManager @@ -421,7 +422,7 @@ else { try { - properties = await PropertyManagementService.GetPropertiesAsync(); + properties = await PropertyService.GetAllAsync(); } catch (Exception ex) { @@ -461,7 +462,7 @@ else { if (selectedPropertyId != Guid.Empty) { - leases = await PropertyManagementService.GetCurrentAndUpcomingLeasesByPropertyIdAsync(selectedPropertyId); + leases = await LeaseService.GetCurrentAndUpcomingLeasesByPropertyIdAsync(selectedPropertyId); } else { diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor index 4dd0971..c7e25db 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor @@ -11,7 +11,6 @@ @using Microsoft.AspNetCore.Components @inject ChecklistService ChecklistService -@inject PropertyManagementService PropertyManagementService @inject UserContextService UserContext @inject NavigationManager NavigationManager @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor index 270c896..97a70c5 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor @@ -4,7 +4,6 @@ @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.SimpleStart.Core.Constants @using Aquiis.SimpleStart.Application.Services -@using Aquiis.SimpleStart.Shared.Services @using Aquiis.SimpleStart.Application.Services.PdfGenerators @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components @@ -12,11 +11,10 @@ @using Microsoft.JSInterop @inject ChecklistService ChecklistService -@inject PropertyManagementService PropertyManagementService @inject UserContextService UserContext @inject NavigationManager NavigationManager @inject ChecklistPdfGenerator PdfGenerator -@inject Aquiis.SimpleStart.Shared.Services.DocumentService DocumentService +@inject Application.Services.DocumentService DocumentService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -456,7 +454,7 @@ else isGeneratingPdf = true; errorMessage = null; - var document = await DocumentService.GetDocumentByIdAsync(checklist.DocumentId.Value); + var document = await DocumentService.GetByIdAsync(checklist.DocumentId.Value); if (document != null) { var filename = $"Checklist_{checklist.Name.Replace(" ", "_")}_{DateTime.Now:yyyyMMdd}.pdf"; @@ -486,7 +484,7 @@ else isGeneratingPdf = true; errorMessage = null; - var document = await DocumentService.GetDocumentByIdAsync(checklist.DocumentId.Value); + var document = await DocumentService.GetByIdAsync(checklist.DocumentId.Value); if (document != null) { await JSRuntime.InvokeVoidAsync("viewFile", Convert.ToBase64String(document.FileData), document.FileType); @@ -541,7 +539,7 @@ else }; // Save document to database - var savedDocument = await DocumentService.UploadDocumentAsync(document); + var savedDocument = await DocumentService.CreateAsync(document); // Update checklist with document reference checklist.DocumentId = savedDocument.Id; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/Documents.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/Documents.razor index 2f926eb..bf8342f 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/Documents.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/Documents.razor @@ -6,7 +6,9 @@ @using Microsoft.AspNetCore.Components.Authorization @inject IJSRuntime JSRuntime -@inject PropertyManagementService PropertyManagementService +@inject Application.Services.DocumentService DocumentService +@inject LeaseService LeaseService +@inject PropertyService PropertyService @inject NavigationManager Navigation @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -382,7 +384,7 @@ else } // Get all documents from last 30 days for the user - var allUserDocs = await PropertyManagementService.GetDocumentsAsync(); + var allUserDocs = await DocumentService.GetAllAsync(); var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); allDocuments = allUserDocs .Where(d => d.CreatedOn >= thirtyDaysAgo && !d.IsDeleted) @@ -390,10 +392,10 @@ else .ToList(); // Load all leases and properties - var allLeases = await PropertyManagementService.GetLeasesAsync(); + var allLeases = await LeaseService.GetAllAsync(); leases = allLeases.Where(l => !l.IsDeleted).ToList(); - var allProperties = await PropertyManagementService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); properties = allProperties.Where(p => !p.IsDeleted).ToList(); ApplyFilters(); @@ -547,7 +549,7 @@ else { try { - await PropertyManagementService.DeleteDocumentAsync(doc); + await DocumentService.DeleteAsync(doc.Id); await LoadDocuments(); } catch (Exception ex) diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor index ae6b75a..1caf011 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor @@ -4,7 +4,8 @@ @using Aquiis.SimpleStart.Core.Entities @using Microsoft.AspNetCore.Authorization @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject Application.Services.DocumentService DocumentService +@inject LeaseService LeaseService @inject UserContextService UserContext @inject IJSRuntime JSRuntime @@ -203,7 +204,7 @@ else private async Task LoadLease() { - lease = await PropertyManagementService.GetLeaseByIdAsync(LeaseId); + lease = await LeaseService.GetByIdAsync(LeaseId); if (lease == null) { Navigation.NavigateTo("/propertymanagement/leases"); @@ -212,7 +213,7 @@ else private async Task LoadDocuments() { - documents = await PropertyManagementService.GetDocumentsByLeaseIdAsync(LeaseId); + documents = await DocumentService.GetDocumentsByLeaseIdAsync(LeaseId); } private void ShowUploadDialog() @@ -267,7 +268,7 @@ else LeaseId = LeaseId, }; - await PropertyManagementService.AddDocumentAsync(document); + await DocumentService.CreateAsync(document); await LoadDocuments(); CancelUpload(); } @@ -300,7 +301,7 @@ else { if (await JSRuntime.InvokeAsync("confirm", $"Are you sure you want to delete '{doc.FileName}'?")) { - await PropertyManagementService.DeleteDocumentAsync(doc); + await DocumentService.DeleteAsync(doc.Id); await LoadDocuments(); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor index 2b1465f..9453cec 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor @@ -11,7 +11,9 @@ @using Microsoft.AspNetCore.Components.Forms @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyManagementService +@inject InspectionService InspectionService +@inject PropertyService PropertyService +@inject LeaseService LeaseService @inject UserContextService UserContext @inject NavigationManager NavigationManager @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -289,7 +291,7 @@ else if (PropertyId.HasValue) { - property = await PropertyManagementService.GetPropertyByIdAsync(PropertyId.Value); + property = await PropertyService.GetByIdAsync(PropertyId.Value); if (property == null) { @@ -300,7 +302,7 @@ else model.PropertyId = PropertyId.Value; // Check if there's an active lease - var activeLeases = await PropertyManagementService.GetActiveLeasesByPropertyIdAsync(PropertyId.Value); + var activeLeases = await LeaseService.GetActiveLeasesByPropertyIdAsync(PropertyId.Value); if (activeLeases.Any()) { model.LeaseId = activeLeases.First().Id; @@ -387,7 +389,7 @@ else }; // Add the inspection - await PropertyManagementService.AddInspectionAsync(inspection); + await InspectionService.CreateAsync(inspection); successMessage = "Inspection saved successfully!"; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor index 40781a5..eb37e12 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor @@ -4,7 +4,7 @@ @using Aquiis.SimpleStart.Core.Entities @using Microsoft.AspNetCore.Authorization -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject NavigationManager NavigationManager @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -275,9 +275,9 @@ else isLoading = true; try { - allProperties = await PropertyManagementService.GetPropertiesAsync(); - overdueProperties = await PropertyManagementService.GetPropertiesWithOverdueInspectionsAsync(); - dueSoonProperties = await PropertyManagementService.GetPropertiesWithInspectionsDueSoonAsync(30); + allProperties = await PropertyService.GetAllAsync(); + overdueProperties = await PropertyService.GetPropertiesWithOverdueInspectionsAsync(); + dueSoonProperties = await PropertyService.GetPropertiesWithInspectionsDueSoonAsync(30); scheduledProperties = allProperties .Where(p => p.NextRoutineInspectionDueDate.HasValue && diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor index 6ff8260..ded2ff1 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor @@ -8,7 +8,8 @@ @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components -@inject PropertyManagementService PropertyManagementService +@inject InspectionService InspectionService +@inject Application.Services.DocumentService DocumentService @inject UserContextService UserContext @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime @@ -232,12 +233,12 @@ else protected override async Task OnInitializedAsync() { - inspection = await PropertyManagementService.GetInspectionByIdAsync(InspectionId); + inspection = await InspectionService.GetByIdAsync(InspectionId); // Load the document if it exists if (inspection?.DocumentId != null) { - document = await PropertyManagementService.GetDocumentByIdAsync(inspection.DocumentId.Value); + document = await DocumentService.GetByIdAsync(inspection.DocumentId.Value); } } @@ -365,11 +366,11 @@ else Description = $"{inspection.InspectionType} Inspection - {inspection.CompletedOn:MMM dd, yyyy}" }; - await PropertyManagementService.AddDocumentAsync(newDocument); + await DocumentService.CreateAsync(newDocument); // Link the document to the inspection inspection.DocumentId = newDocument.Id; - await PropertyManagementService.UpdateInspectionAsync(inspection); + await InspectionService.UpdateAsync(inspection); document = newDocument; successMessage = "Inspection PDF generated and saved successfully!"; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/CreateInvoice.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/CreateInvoice.razor index 5058248..cce9b50 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/CreateInvoice.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/CreateInvoice.razor @@ -7,7 +7,8 @@ @using Microsoft.AspNetCore.Components.Forms @using System.ComponentModel.DataAnnotations @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject InvoiceService InvoiceService +@inject LeaseService LeaseService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -196,7 +197,7 @@ protected override async Task OnInitializedAsync() { await LoadLeases(); - invoiceModel.InvoiceNumber = await PropertyManagementService.GenerateInvoiceNumberAsync(); + invoiceModel.InvoiceNumber = await InvoiceService.GenerateInvoiceNumberAsync(); invoiceModel.InvoicedOn = DateTime.Now; invoiceModel.DueOn = DateTime.Now.AddDays(30); if (LeaseId.HasValue) @@ -208,7 +209,7 @@ private async Task LoadLeases() { - var allLeases = await PropertyManagementService.GetLeasesAsync(); + var allLeases = await LeaseService.GetAllAsync(); leases = allLeases.Where(l => l.Status == "Active").ToList(); } @@ -259,7 +260,7 @@ Notes = invoiceModel.Notes ?? string.Empty }; - await PropertyManagementService.AddInvoiceAsync(invoice); + await InvoiceService.CreateAsync(invoice); NavigationManager.NavigateTo("/propertymanagement/invoices"); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/EditInvoice.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/EditInvoice.razor index 2331cfe..76d7373 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/EditInvoice.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/EditInvoice.razor @@ -8,7 +8,8 @@ @using Microsoft.AspNetCore.Components.Forms @using System.ComponentModel.DataAnnotations @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject InvoiceService InvoiceService +@inject LeaseService LeaseService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -245,7 +246,7 @@ else private async Task LoadInvoice() { - invoice = await PropertyManagementService.GetInvoiceByIdAsync(Id); + invoice = await InvoiceService.GetByIdAsync(Id); if (invoice != null) { @@ -272,7 +273,7 @@ else if (!string.IsNullOrEmpty(userId)) { - leases = await PropertyManagementService.GetLeasesAsync(); + leases = await LeaseService.GetAllAsync(); } } @@ -308,7 +309,7 @@ else invoice.PaidOn = invoiceModel.PaidOn; invoice.Notes = invoiceModel.Notes ?? string.Empty; - await PropertyManagementService.UpdateInvoiceAsync(invoice); + await InvoiceService.UpdateAsync(invoice); successMessage = "Invoice updated successfully!"; } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/Invoices.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/Invoices.razor index debd08f..7d6bab7 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/Invoices.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/Invoices.razor @@ -5,7 +5,7 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Forms @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject InvoiceService InvoiceService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -412,7 +412,7 @@ else if (!string.IsNullOrEmpty(userId)) { - invoices = await PropertyManagementService.GetInvoicesAsync(); + invoices = await InvoiceService.GetAllAsync(); if (LeaseId.HasValue) { invoices = invoices.Where(i => i.LeaseId == LeaseId.Value).ToList(); @@ -585,7 +585,7 @@ else { if (await JSRuntime.InvokeAsync("confirm", $"Are you sure you want to delete invoice {invoice.InvoiceNumber}?")) { - await PropertyManagementService.DeleteInvoiceAsync(invoice); + await InvoiceService.DeleteAsync(invoice.Id); await LoadInvoices(); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/ViewInvoice.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/ViewInvoice.razor index 75ed4b4..b3669aa 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/ViewInvoice.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Invoices/Pages/ViewInvoice.razor @@ -5,7 +5,8 @@ @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Web @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject InvoiceService InvoiceService +@inject Application.Services.DocumentService DocumentService @inject UserContextService UserContextService @inject IJSRuntime JSRuntime @@ -288,12 +289,12 @@ else private async Task LoadInvoice() { - invoice = await PropertyManagementService.GetInvoiceByIdAsync(Id); + invoice = await InvoiceService.GetByIdAsync(Id); // Load the document if it exists if (invoice?.DocumentId != null) { - document = await PropertyManagementService.GetDocumentByIdAsync(invoice.DocumentId.Value); + document = await DocumentService.GetByIdAsync(invoice.DocumentId.Value); } } @@ -381,12 +382,12 @@ else }; // Save to database - await PropertyManagementService.AddDocumentAsync(document); + await DocumentService.CreateAsync(document); // Update invoice with DocumentId invoice.DocumentId = document.Id; - await PropertyManagementService.UpdateInvoiceAsync(invoice); + await InvoiceService.UpdateAsync(invoice); // Reload invoice and document await LoadInvoice(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/LeaseOffers.razor b/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/LeaseOffers.razor index 42e30a2..7183903 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/LeaseOffers.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/LeaseOffers.razor @@ -9,7 +9,8 @@ @inject NavigationManager Navigation @inject UserContextService UserContext -@inject PropertyManagementService PropertyService +@inject RentalApplicationService RentalApplicationService +@inject LeaseOfferService LeaseOfferService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -128,11 +129,11 @@ else var allOffers = new List(); // We'll need to get offers from all applications - var applications = await PropertyService.GetAllRentalApplicationsAsync(); + var applications = await RentalApplicationService.GetAllAsync(); foreach (var app in applications) { - var offer = await PropertyService.GetLeaseOfferByApplicationIdAsync(app.Id); + var offer = await LeaseOfferService.GetLeaseOfferByApplicationIdAsync(app.Id); if (offer != null && !offer.IsDeleted) { allOffers.Add(offer); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/ViewLeaseOffer.razor b/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/ViewLeaseOffer.razor index 8c44f52..f0f43d2 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/ViewLeaseOffer.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/LeaseOffers/Pages/ViewLeaseOffer.razor @@ -12,7 +12,7 @@ @using Microsoft.EntityFrameworkCore @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject LeaseOfferService LeaseOfferService @inject ApplicationWorkflowService WorkflowService @inject ApplicationDbContext DbContext @inject NavigationManager Navigation @@ -434,7 +434,7 @@ private async Task LoadLeaseOffer() { - leaseOffer = await PropertyService.GetLeaseOfferByIdAsync(Id); + leaseOffer = await LeaseOfferService.GetLeaseOfferWithRelationsAsync(Id); } private int CalculateDuration() diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/AcceptLease.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/AcceptLease.razor index 3b6eb83..53563a9 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/AcceptLease.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/AcceptLease.razor @@ -9,7 +9,10 @@ @using System.Net @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyService +@inject LeaseService LeaseService +@inject PropertyService PropertyService +@inject RentalApplicationService RentalApplicationService +@inject ProspectiveTenantService ProspectiveTenantService @inject TenantConversionService TenantConversionService @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthStateProvider @@ -361,7 +364,7 @@ private async Task LoadLease() { - lease = await PropertyService.GetLeaseByIdAsync(LeaseId); + lease = await LeaseService.GetByIdAsync(LeaseId); if (lease != null) { @@ -374,14 +377,14 @@ // Find the application and prospective tenant if (lease.Property != null) { - var allApplications = await PropertyService.GetAllRentalApplicationsAsync(); + var allApplications = await RentalApplicationService.GetAllAsync(); application = allApplications.FirstOrDefault(a => a.PropertyId == lease.PropertyId && a.Status == ApplicationConstants.ApplicationStatuses.Approved); if (application != null) { - prospectiveTenant = await PropertyService.GetProspectiveTenantByIdAsync( + prospectiveTenant = await ProspectiveTenantService.GetByIdAsync( application.ProspectiveTenantId); } } @@ -466,7 +469,7 @@ } - await PropertyService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); // Update property status to Occupied if (lease.Property != null) @@ -474,7 +477,7 @@ lease.Property.Status = ApplicationConstants.PropertyStatuses.Occupied; lease.Property.IsAvailable = false; - await PropertyService.UpdatePropertyAsync(lease.Property); + await PropertyService.UpdateAsync(lease.Property); } // Update application status @@ -482,13 +485,13 @@ { application.Status = "LeaseAccepted"; // We'll add this status - await PropertyService.UpdateRentalApplicationAsync(application); + await RentalApplicationService.UpdateAsync(application); } // Update prospect status to ConvertedToTenant prospectiveTenant.Status = ApplicationConstants.ProspectiveStatuses.ConvertedToTenant; - await PropertyService.UpdateProspectiveTenantAsync(prospectiveTenant); + await ProspectiveTenantService.UpdateAsync(prospectiveTenant); ToastService.ShowSuccess($"Lease accepted! Tenant {tenant.FullName} created successfully."); @@ -523,10 +526,10 @@ lease.Notes += $"\n\nDeclined on: {DateTime.UtcNow:MMM dd, yyyy h:mm tt UTC}\n" + $"Declined by: {userId}\n" + $"Reason: {(string.IsNullOrWhiteSpace(declineReason) ? "Not specified" : declineReason)}"; - await PropertyService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); // Check if there are other pending applications - var allApplications = await PropertyService.GetAllRentalApplicationsAsync(); + var allApplications = await RentalApplicationService.GetAllAsync(); var otherPendingApps = allApplications.Any(a => a.PropertyId == lease.PropertyId && (a.Status == ApplicationConstants.ApplicationStatuses.Submitted || @@ -541,7 +544,7 @@ : ApplicationConstants.PropertyStatuses.Available; lease.Property.IsAvailable = !otherPendingApps; - await PropertyService.UpdatePropertyAsync(lease.Property); + await PropertyService.UpdateAsync(lease.Property); } // Update application and prospect status @@ -549,14 +552,14 @@ { application.Status = "LeaseDeclined"; - await PropertyService.UpdateRentalApplicationAsync(application); + await RentalApplicationService.UpdateAsync(application); } if (prospectiveTenant != null) { prospectiveTenant.Status = "LeaseDeclined"; - await PropertyService.UpdateProspectiveTenantAsync(prospectiveTenant); + await ProspectiveTenantService.UpdateAsync(prospectiveTenant); } showDeclineModal = false; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/CreateLease.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/CreateLease.razor index 0d5b0ea..e245093 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/CreateLease.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/CreateLease.razor @@ -11,7 +11,10 @@ @using System.ComponentModel.DataAnnotations @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject OrganizationService OrganizationService +@inject LeaseService LeaseService +@inject PropertyService PropertyService +@inject TenantService TenantService @inject AuthenticationStateProvider AuthenticationStateProvider @@ -219,14 +222,14 @@ return; // Load available properties (only available ones) - List? allProperties = await PropertyManagementService.GetPropertiesAsync(); + List? allProperties = await PropertyService.GetAllAsync(); availableProperties = allProperties .Where(p => p.IsAvailable) .ToList() ?? new List(); // Load user's tenants - userTenants = await PropertyManagementService.GetTenantsAsync(); + userTenants = await TenantService.GetAllAsync(); userTenants = userTenants .Where(t => t.IsActive) .ToList(); @@ -245,7 +248,7 @@ if (selectedProperty != null) { // Get organization settings for security deposit calculation - var settings = await PropertyManagementService.GetOrganizationSettingsAsync(); + var settings = await OrganizationService.GetOrganizationSettingsAsync(); var depositMultiplier = settings?.AutoCalculateSecurityDeposit == true ? settings.SecurityDepositMultiplier : 1.0m; @@ -279,8 +282,8 @@ } // Verify property and tenant belong to user - var property = await PropertyManagementService.GetPropertyByIdAsync(leaseModel.PropertyId); - var tenant = await PropertyManagementService.GetTenantByIdAsync(leaseModel.TenantId); + var property = await PropertyService.GetByIdAsync(leaseModel.PropertyId); + var tenant = await TenantService.GetByIdAsync(leaseModel.TenantId); if (property == null) { @@ -308,7 +311,7 @@ Notes = leaseModel.Notes }; - await PropertyManagementService.AddLeaseAsync(lease); + await LeaseService.CreateAsync(lease); // Mark property as unavailable if lease is active if (leaseModel.Status == ApplicationConstants.LeaseStatuses.Active) @@ -316,7 +319,7 @@ property.IsAvailable = false; } - await PropertyManagementService.UpdatePropertyAsync(property); + await PropertyService.UpdateAsync(property); Navigation.NavigateTo("/propertymanagement/leases"); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/EditLease.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/EditLease.razor index 057b539..17e9744 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/EditLease.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/EditLease.razor @@ -8,7 +8,7 @@ @using System.ComponentModel.DataAnnotations @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject LeaseService LeaseService @inject UserContextService UserContextService @rendermode InteractiveServer @@ -210,7 +210,7 @@ else private async Task LoadLease() { - lease = await PropertyManagementService.GetLeaseByIdAsync(Id); + lease = await LeaseService.GetByIdAsync(Id); if (lease == null) { @@ -258,7 +258,7 @@ else else if (oldStatus == "Active" && leaseModel.Status != "Active") { // Check if there are other active leases for this property - var activeLeases = await PropertyManagementService.GetActiveLeasesByPropertyIdAsync(lease.PropertyId); + var activeLeases = await LeaseService.GetActiveLeasesByPropertyIdAsync(lease.PropertyId); var otherActiveLeases = activeLeases.Any(l => l.PropertyId == lease.PropertyId && l.Id != Id && l.Status == "Active"); if (!otherActiveLeases) @@ -268,7 +268,7 @@ else } } - await PropertyManagementService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); successMessage = "Lease updated successfully!"; statusChangeWarning = false; } @@ -312,7 +312,7 @@ else // If deleting an active lease, make property available if (lease.Status == "Active" && lease.Property != null) { - var otherActiveLeases = await PropertyManagementService.GetActiveLeasesByPropertyIdAsync(lease.PropertyId); + var otherActiveLeases = await LeaseService.GetActiveLeasesByPropertyIdAsync(lease.PropertyId); var otherActiveLeasesExist = otherActiveLeases.Any(l => l.Id != Id && l.Status == "Active"); if (!otherActiveLeasesExist) @@ -321,7 +321,7 @@ else } } - await PropertyManagementService.DeleteLeaseAsync(lease.Id); + await LeaseService.DeleteAsync(lease.Id); Navigation.NavigateTo("/propertymanagement/leases"); } catch (Exception ex) diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/Leases.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/Leases.razor index e9bc97a..778aca5 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/Leases.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/Leases.razor @@ -9,7 +9,9 @@ @using Aquiis.SimpleStart.Shared.Components.Account @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject LeaseService LeaseService +@inject TenantService TenantService +@inject PropertyService PropertyService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -570,12 +572,12 @@ else if (TenantId.HasValue) { - filterTenant = await PropertyManagementService.GetTenantByIdAsync(TenantId.Value); + filterTenant = await TenantService.GetByIdAsync(TenantId.Value); } if (PropertyId.HasValue) { - filterProperty = await PropertyManagementService.GetPropertyByIdAsync(PropertyId.Value); + filterProperty = await PropertyService.GetByIdAsync(PropertyId.Value); } } @@ -590,7 +592,7 @@ else return; } - var allLeases = await PropertyManagementService.GetLeasesAsync(); + var allLeases = await LeaseService.GetAllAsync(); leases = allLeases .Where(l => (!TenantId.HasValue || l.TenantId == TenantId.Value) && @@ -829,7 +831,7 @@ else if (!confirmed) return; - await PropertyManagementService.DeleteLeaseAsync(id); + await LeaseService.DeleteAsync(id); await LoadLeases(); } } \ No newline at end of file diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/ViewLease.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/ViewLease.razor index e34e07f..cfe7713 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/ViewLease.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Leases/Pages/ViewLease.razor @@ -14,11 +14,14 @@ @using Aquiis.SimpleStart.Application.Services.PdfGenerators @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject LeaseService LeaseService +@inject InvoiceService InvoiceService +@inject Application.Services.DocumentService DocumentService @inject LeaseWorkflowService LeaseWorkflowService @inject UserContextService UserContextService @inject LeaseRenewalPdfGenerator RenewalPdfGenerator @inject ToastService ToastService +@inject OrganizationService OrganizationService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -733,7 +736,7 @@ else return; } - lease = await PropertyManagementService.GetLeaseByIdAsync(Id); + lease = await LeaseService.GetByIdAsync(Id); if (lease == null) { @@ -741,7 +744,7 @@ else return; } - var invoices = await PropertyManagementService.GetInvoicesByLeaseIdAsync(Id); + var invoices = await InvoiceService.GetInvoicesByLeaseIdAsync(Id); recentInvoices = invoices .OrderByDescending(i => i.DueOn) .Take(5) @@ -750,7 +753,7 @@ else // Load the document if it exists if (lease.DocumentId != null) { - document = await PropertyManagementService.GetDocumentByIdAsync(lease.DocumentId.Value); + document = await DocumentService.GetByIdAsync(lease.DocumentId.Value); } } @@ -798,7 +801,7 @@ else lease.RenewalOfferedOn = DateTime.UtcNow; lease.RenewalNotes = renewalNotes; - await PropertyManagementService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); // TODO: Send email notification to tenant @@ -849,7 +852,7 @@ else Description = $"Renewal offer letter for {lease.Property?.Address}. Proposed rent: {lease.ProposedRenewalRent:C}" }; - await PropertyManagementService.AddDocumentAsync(document); + await DocumentService.CreateAsync(document); ToastService.ShowSuccess($"Renewal offer letter generated and saved to documents!"); } @@ -908,7 +911,7 @@ else { lease.RenewalStatus = "Declined"; lease.RenewalResponseOn = DateTime.UtcNow; - await PropertyManagementService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); await LoadLease(); StateHasChanged(); @@ -1114,7 +1117,7 @@ else if (selectedProperty != null) { // Get organization settings for security deposit calculation - var settings = await PropertyManagementService.GetOrganizationSettingsAsync(); + var settings = await OrganizationService.GetOrganizationSettingsAsync(); var depositMultiplier = settings?.AutoCalculateSecurityDeposit == true ? settings.SecurityDepositMultiplier : 1.0m; @@ -1202,12 +1205,12 @@ else }; // Save to database - await PropertyManagementService.AddDocumentAsync(document); + await DocumentService.CreateAsync(document); // Update lease with DocumentId lease.DocumentId = document.Id; - await PropertyManagementService.UpdateLeaseAsync(lease); + await LeaseService.UpdateAsync(lease); // Reload lease and document await LoadLease(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/CreateMaintenanceRequest.razor b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/CreateMaintenanceRequest.razor index 6bb8372..fbdc08c 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/CreateMaintenanceRequest.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/CreateMaintenanceRequest.razor @@ -4,7 +4,9 @@ @using Aquiis.SimpleStart.Application.Services.PdfGenerators @using Microsoft.Extensions.Configuration.UserSecrets @using System.ComponentModel.DataAnnotations -@inject PropertyManagementService PropertyManagementService +@inject MaintenanceService MaintenanceService +@inject PropertyService PropertyService +@inject LeaseService LeaseService @inject NavigationManager NavigationManager @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -242,7 +244,7 @@ isLoading = true; try { - properties = await PropertyManagementService.GetPropertiesAsync(); + properties = await PropertyService.GetAllAsync(); if (PropertyId.HasValue && PropertyId.Value != Guid.Empty) { @@ -275,7 +277,7 @@ private async Task LoadLeaseForProperty(Guid propertyId) { - var leases = await PropertyManagementService.GetCurrentAndUpcomingLeasesByPropertyIdAsync(propertyId); + var leases = await LeaseService.GetCurrentAndUpcomingLeasesByPropertyIdAsync(propertyId); currentLease = leases.FirstOrDefault(); maintenanceRequest.LeaseId = currentLease?.Id; } @@ -302,7 +304,7 @@ AssignedTo = maintenanceRequest.AssignedTo }; - await PropertyManagementService.AddMaintenanceRequestAsync(request); + await MaintenanceService.CreateAsync(request); NavigationManager.NavigateTo("/propertymanagement/maintenance"); } finally diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/EditMaintenanceRequest.razor b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/EditMaintenanceRequest.razor index 414455c..545ed1b 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/EditMaintenanceRequest.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/EditMaintenanceRequest.razor @@ -1,5 +1,7 @@ @page "/propertymanagement/maintenance/edit/{Id:guid}" -@inject PropertyManagementService PropertyManagementService +@inject MaintenanceService MaintenanceService +@inject PropertyService PropertyService +@inject LeaseService LeaseService @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -237,8 +239,8 @@ isLoading = true; try { - maintenanceRequest = await PropertyManagementService.GetMaintenanceRequestByIdAsync(Id); - properties = await PropertyManagementService.GetPropertiesAsync(); + maintenanceRequest = await MaintenanceService.GetByIdAsync(Id); + properties = await PropertyService.GetAllAsync(); if (maintenanceRequest?.PropertyId != null) { @@ -265,7 +267,7 @@ private async Task LoadLeasesForProperty(Guid propertyId) { - var allLeases = await PropertyManagementService.GetLeasesByPropertyIdAsync(propertyId); + var allLeases = await LeaseService.GetLeasesByPropertyIdAsync(propertyId); availableLeases = allLeases.Where(l => l.Status == "Active" || l.Status == "Pending").ToList(); } @@ -276,7 +278,7 @@ isSaving = true; try { - await PropertyManagementService.UpdateMaintenanceRequestAsync(maintenanceRequest); + await MaintenanceService.UpdateAsync(maintenanceRequest); NavigationManager.NavigateTo($"/propertymanagement/maintenance/view/{Id}"); } finally @@ -292,7 +294,7 @@ var confirmed = await JSRuntime.InvokeAsync("confirm", "Are you sure you want to delete this maintenance request?"); if (confirmed) { - await PropertyManagementService.DeleteMaintenanceRequestAsync(Id); + await MaintenanceService.DeleteAsync(Id); NavigationManager.NavigateTo("/propertymanagement/maintenance"); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/MaintenanceRequests.razor b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/MaintenanceRequests.razor index 07101b2..bd605ee 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/MaintenanceRequests.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/MaintenanceRequests.razor @@ -1,5 +1,5 @@ @page "/propertymanagement/maintenance" -@inject PropertyManagementService PropertyManagementService +@inject MaintenanceService MaintenanceService @inject NavigationManager NavigationManager @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -265,7 +265,7 @@ else isLoading = true; try { - allRequests = await PropertyManagementService.GetMaintenanceRequestsAsync(); + allRequests = await MaintenanceService.GetAllAsync(); if (PropertyId.HasValue) { @@ -277,7 +277,7 @@ else inProgressRequests = allRequests.Where(r => r.Status == "In Progress").ToList(); submittedRequests = allRequests.Where(r => r.Status == "Submitted").ToList(); completedRequests = allRequests.Where(r => r.Status == "Completed" && r.CompletedOn?.Month == DateTime.Today.Month).ToList(); - overdueRequests = await PropertyManagementService.GetOverdueMaintenanceRequestsAsync(); + overdueRequests = await MaintenanceService.GetOverdueMaintenanceRequestsAsync(); ApplyFilters(); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/ViewMaintenanceRequest.razor b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/ViewMaintenanceRequest.razor index c1ef3fc..b490f03 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/ViewMaintenanceRequest.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/ViewMaintenanceRequest.razor @@ -4,7 +4,7 @@ @using Aquiis.SimpleStart.Shared.Services @using Aquiis.SimpleStart.Application.Services.PdfGenerators -@inject PropertyManagementService PropertyManagementService +@inject MaintenanceService MaintenanceService @inject NavigationManager NavigationManager @inject ToastService ToastService @@ -271,7 +271,7 @@ else isLoading = true; try { - maintenanceRequest = await PropertyManagementService.GetMaintenanceRequestByIdAsync(Id); + maintenanceRequest = await MaintenanceService.GetByIdAsync(Id); } finally { @@ -283,7 +283,7 @@ else { if (maintenanceRequest != null) { - await PropertyManagementService.UpdateMaintenanceRequestStatusAsync(maintenanceRequest.Id, newStatus); + await MaintenanceService.UpdateMaintenanceRequestStatusAsync(maintenanceRequest.Id, newStatus); ToastService.ShowSuccess($"Maintenance request status updated to '{newStatus}'."); await LoadMaintenanceRequest(); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/CreatePayment.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/CreatePayment.razor index 6ca2219..0b9cd96 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/CreatePayment.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/CreatePayment.razor @@ -6,7 +6,8 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Http.HttpResults @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PaymentService PaymentService +@inject InvoiceService InvoiceService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -210,7 +211,7 @@ // Get all invoices and filter to those with outstanding balance - List? allInvoices = await PropertyManagementService.GetInvoicesAsync(); + List? allInvoices = await InvoiceService.GetAllAsync(); invoices = allInvoices .Where(i => i.BalanceDue > 0 && i.Status != "Cancelled") .OrderByDescending(i => i.DueOn) @@ -254,7 +255,7 @@ PaymentMethod = paymentModel.PaymentMethod, Notes = paymentModel.Notes! }; - await PropertyManagementService.AddPaymentAsync(payment); + await PaymentService.CreateAsync(payment); Navigation.NavigateTo("/propertymanagement/payments"); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/EditPayment.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/EditPayment.razor index 208e312..4def041 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/EditPayment.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/EditPayment.razor @@ -5,7 +5,7 @@ @using Microsoft.AspNetCore.Components.Forms @using System.ComponentModel.DataAnnotations @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PaymentService PaymentService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -225,7 +225,7 @@ else protected override async Task OnInitializedAsync() { - payment = await PropertyManagementService.GetPaymentByIdAsync(PaymentId); + payment = await PaymentService.GetByIdAsync(PaymentId); if (payment == null) { @@ -251,7 +251,7 @@ else payment.PaymentMethod = paymentModel.PaymentMethod; payment.Notes = paymentModel.Notes!; - await PropertyManagementService.UpdatePaymentAsync(payment); + await PaymentService.UpdateAsync(payment); Navigation.NavigateTo("/propertymanagement/payments"); } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Payments.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Payments.razor index 36c378d..71cbd66 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Payments.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Payments.razor @@ -5,7 +5,7 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Forms @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PaymentService PaymentService @inject IJSRuntime JSRuntime @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @@ -337,7 +337,7 @@ else if (!string.IsNullOrEmpty(userId)) { - payments = await PropertyManagementService.GetPaymentsAsync(); + payments = await PaymentService.GetAllAsync(); FilterPayments(); UpdateStatistics(); } @@ -485,7 +485,7 @@ else { if (await JSRuntime.InvokeAsync("confirm", $"Are you sure you want to delete this payment of {payment.Amount:C}?")) { - await PropertyManagementService.DeletePaymentAsync(payment); + await PaymentService.DeleteAsync(payment.Id); await LoadPayments(); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/ViewPayment.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/ViewPayment.razor index c03332d..231abf1 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/ViewPayment.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/ViewPayment.razor @@ -5,7 +5,8 @@ @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PaymentService PaymentService +@inject Application.Services.DocumentService DocumentService @inject UserContextService UserContextService @inject IJSRuntime JSRuntime @@ -317,7 +318,7 @@ else protected override async Task OnInitializedAsync() { - payment = await PropertyManagementService.GetPaymentByIdAsync(PaymentId); + payment = await PaymentService.GetByIdAsync(PaymentId); if (payment == null) { @@ -326,7 +327,7 @@ else else if (payment.DocumentId != null) { // Load the document if it exists - document = await PropertyManagementService.GetDocumentByIdAsync(payment.DocumentId.Value); + document = await DocumentService.GetByIdAsync(payment.DocumentId.Value); } } @@ -390,12 +391,12 @@ else }; // Save to database - await PropertyManagementService.AddDocumentAsync(document); + await DocumentService.CreateAsync(document); // Update payment with DocumentId payment.DocumentId = document.Id; - await PropertyManagementService.UpdatePaymentAsync(payment); + await PaymentService.UpdateAsync(payment); // Reload payment and document this.document = document; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Create.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Create.razor index c690e24..025c696 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Create.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Create.razor @@ -6,7 +6,7 @@ @using Microsoft.AspNetCore.Components.Forms @using System.ComponentModel.DataAnnotations @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @rendermode InteractiveServer @@ -195,7 +195,7 @@ }; // Save the property using a service or API call - await PropertyManagementService.AddPropertyAsync(property); + await PropertyService.CreateAsync(property); isSubmitting = false; // Redirect to the properties list page after successful addition diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor index e5f20bf..4b0bcd8 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor @@ -12,7 +12,7 @@ @rendermode InteractiveServer @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject NavigationManager NavigationManager @if (property == null) @@ -256,7 +256,7 @@ else return; } - property = await PropertyManagementService.GetPropertyByIdAsync(PropertyId); + property = await PropertyService.GetByIdAsync(PropertyId); if (property == null) { @@ -287,7 +287,7 @@ else { if (property != null) { - await PropertyManagementService.UpdatePropertyAsync(property); + await PropertyService.UpdateAsync(property); NavigationManager.NavigateTo("/propertymanagement/properties"); } } @@ -296,7 +296,7 @@ else { if (property != null) { - await PropertyManagementService.DeletePropertyAsync(property.Id); + await PropertyService.DeleteAsync(property.Id); NavigationManager.NavigateTo("/propertymanagement/properties"); } } @@ -334,7 +334,7 @@ else property.Status = propertyModel.Status; property.IsAvailable = propertyModel.IsAvailable; - await PropertyManagementService.UpdatePropertyAsync(property); + await PropertyService.UpdateAsync(property); } catch (Exception ex) { errorMessage = $"An error occurred while updating the property: {ex.Message}"; diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor index e485074..c608ad0 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor @@ -3,7 +3,7 @@ @using Aquiis.SimpleStart.Core.Constants @using Microsoft.AspNetCore.Authorization @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject IJSRuntime JSRuntime @inject UserContextService UserContext @@ -383,7 +383,7 @@ else return; } - var allProperties = await PropertyManagementService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); properties = allProperties.Where(p=>p.IsDeleted==false).ToList(); } @@ -442,7 +442,7 @@ else if (string.IsNullOrEmpty(userId)) return; - await PropertyManagementService.DeletePropertyAsync(propertyId); + await PropertyService.DeleteAsync(propertyId); // Add confirmation dialog in a real application await LoadProperties(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor index 93508cf..f4a80c3 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor @@ -4,7 +4,11 @@ @using Aquiis.SimpleStart.Core.Entities @using Microsoft.AspNetCore.Authorization -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService +@inject LeaseService LeaseService +@inject MaintenanceService MaintenanceService +@inject InspectionService InspectionService +@inject Application.Services.DocumentService DocumentService @inject ChecklistService ChecklistService @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime @@ -464,13 +468,13 @@ else isAuthorized = false; return; } - property = await PropertyManagementService.GetPropertyByIdAsync(PropertyId); + property = await PropertyService.GetByIdAsync(PropertyId); if (property == null) { isAuthorized = false; return; } - activeLeases = await PropertyManagementService.GetActiveLeasesByPropertyIdAsync(PropertyId); + activeLeases = await LeaseService.GetActiveLeasesByPropertyIdAsync(PropertyId); Lease? lease = activeLeases.FirstOrDefault(); if (lease != null) @@ -479,17 +483,15 @@ else } // Load documents for this property - var allDocuments = await PropertyManagementService.GetDocumentsAsync(); - propertyDocuments = allDocuments - .Where(d => d.PropertyId == PropertyId && !d.IsDeleted) - .OrderByDescending(d => d.CreatedOn) + propertyDocuments = await DocumentService.GetDocumentsByPropertyIdAsync(PropertyId); + propertyDocuments = propertyDocuments + .Where(d => !d.IsDeleted) .ToList(); // Load maintenance requests for this property - maintenanceRequests = await PropertyManagementService.GetMaintenanceRequestsByPropertyAsync(PropertyId); - + maintenanceRequests = await MaintenanceService.GetMaintenanceRequestsByPropertyAsync(PropertyId); // Load inspections for this property - propertyInspections = await PropertyManagementService.GetInspectionsByPropertyIdAsync(PropertyId); + propertyInspections = await InspectionService.GetByPropertyIdAsync(PropertyId); // Load checklists for this property var allChecklists = await ChecklistService.GetChecklistsAsync(includeArchived: false); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/IncomeStatementReport.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/IncomeStatementReport.razor index c1f12b7..5124a69 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/IncomeStatementReport.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/IncomeStatementReport.razor @@ -7,7 +7,7 @@ @using Microsoft.AspNetCore.Authorization @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @inject FinancialReportService FinancialReportService -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject FinancialReportPdfGenerator PdfGenerator @inject AuthenticationStateProvider AuthenticationStateProvider @@ -185,7 +185,7 @@ if (organizationId.HasValue) { - properties = await PropertyManagementService.GetPropertiesAsync(); + properties = await PropertyService.GetAllAsync(); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/TaxReport.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/TaxReport.razor index 8d0c511..f878073 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/TaxReport.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Reports/Pages/TaxReport.razor @@ -7,7 +7,7 @@ @using Microsoft.AspNetCore.Authorization @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @inject FinancialReportService FinancialReportService -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService @inject FinancialReportPdfGenerator PdfGenerator @inject AuthenticationStateProvider AuthenticationStateProvider @@ -235,7 +235,7 @@ if (!string.IsNullOrEmpty(userId)) { - properties = await PropertyManagementService.GetPropertiesAsync(); + properties = await PropertyService.GetAllAsync(); } } diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/SecurityDeposits/Pages/RecordPoolPerformance.razor b/Aquiis.SimpleStart/Features/PropertyManagement/SecurityDeposits/Pages/RecordPoolPerformance.razor index bec2735..9858a6a 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/SecurityDeposits/Pages/RecordPoolPerformance.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/SecurityDeposits/Pages/RecordPoolPerformance.razor @@ -6,7 +6,7 @@ @using Aquiis.SimpleStart.Application.Services.PdfGenerators @using Microsoft.AspNetCore.Authorization @inject SecurityDepositService SecurityDepositService -@inject PropertyManagementService PropertyManagementService +@inject OrganizationService OrganizationService @inject NavigationManager NavigationManager @inject ToastService ToastService @rendermode InteractiveServer @@ -267,7 +267,7 @@ performanceModel.Year = Year.Value; // Load organization settings - organizationSettings = await PropertyManagementService.GetOrganizationSettingsAsync(); + organizationSettings = await OrganizationService.GetOrganizationSettingsAsync(); // Check if pool already exists for this year existingPool = await SecurityDepositService.GetInvestmentPoolByYearAsync(Year.Value); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Create.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Create.razor index 60ab75c..84a03ac 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Create.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Create.razor @@ -6,7 +6,7 @@ @using Aquiis.SimpleStart.Application.Services.PdfGenerators @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization -@inject PropertyManagementService PropertyManagementService +@inject TenantService TenantService @inject NavigationManager NavigationManager @inject ToastService ToastService @rendermode InteractiveServer @@ -134,7 +134,7 @@ // Check for duplicate identification number if (!string.IsNullOrWhiteSpace(tenantModel.IdentificationNumber)) { - var existingTenant = await PropertyManagementService.GetTenantByIdentificationNumberAsync(tenantModel.IdentificationNumber); + var existingTenant = await TenantService.GetTenantByIdentificationNumberAsync(tenantModel.IdentificationNumber); if (existingTenant != null) { errorMessage = $"A tenant with identification number {tenantModel.IdentificationNumber} already exists. " + @@ -158,7 +158,7 @@ IsActive = true }; - await PropertyManagementService.AddTenantAsync(tenant); + await TenantService.CreateAsync(tenant); ToastService.ShowSuccess($"Tenant {tenant.FullName} created successfully!"); NavigationManager.NavigateTo("/propertymanagement/tenants"); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/EditTenant.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/EditTenant.razor index f9f4cb2..515b748 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/EditTenant.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/EditTenant.razor @@ -8,7 +8,7 @@ @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject TenantService TenantService @rendermode InteractiveServer @if (tenant == null) @@ -202,7 +202,7 @@ else return; } - tenant = await PropertyManagementService.GetTenantByIdAsync(Id); + tenant = await TenantService.GetByIdAsync(Id); if (tenant == null) { @@ -255,7 +255,7 @@ else tenant.EmergencyContactPhone = tenantModel.EmergencyContactPhone; tenant.Notes = tenantModel.Notes; - await PropertyManagementService.UpdateTenantAsync(tenant); + await TenantService.UpdateAsync(tenant); successMessage = "Tenant updated successfully!"; } catch (Exception ex) @@ -289,7 +289,7 @@ else { try { - await PropertyManagementService.DeleteTenantAsync(tenant); + await TenantService.DeleteAsync(tenant.Id); NavigationManager.NavigateTo("/propertymanagement/tenants"); } catch (Exception ex) diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Tenants.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Tenants.razor index 6bbf4f7..253c47f 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Tenants.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/Tenants.razor @@ -2,7 +2,7 @@ @using Aquiis.SimpleStart.Features.PropertyManagement @using Microsoft.AspNetCore.Authorization @inject NavigationManager Navigation -@inject PropertyManagementService PropertyManagementService +@inject TenantService TenantService @inject IJSRuntime JSRuntime @inject UserContextService UserContext @@ -340,7 +340,7 @@ else return; } - tenants = await PropertyManagementService.GetTenantsAsync(); + tenants = await TenantService.GetAllAsync(); } private void CreateTenant() @@ -368,11 +368,11 @@ else // Add confirmation dialog in a real application - var tenant = await PropertyManagementService.GetTenantByIdAsync(id); + var tenant = await TenantService.GetByIdAsync(id); if (tenant != null) { - await PropertyManagementService.DeleteTenantAsync(tenant); + await TenantService.DeleteAsync(tenant.Id); await LoadTenants(); FilterTenants(); CalculateMetrics(); diff --git a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/View.razor b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/View.razor index 88da40f..0566ddf 100644 --- a/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/View.razor +++ b/Aquiis.SimpleStart/Features/PropertyManagement/Tenants/Pages/View.razor @@ -2,7 +2,8 @@ @using Aquiis.SimpleStart.Core.Entities @using Microsoft.AspNetCore.Authorization @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject TenantService TenantService +@inject LeaseService LeaseService @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @rendermode InteractiveServer @@ -201,7 +202,7 @@ else return; } - tenant = await PropertyManagementService.GetTenantByIdAsync(Id); + tenant = await TenantService.GetByIdAsync(Id); if (tenant == null) { @@ -210,7 +211,7 @@ else } // Load leases for this tenant - tenantLeases = await PropertyManagementService.GetLeasesByTenantIdAsync(Id); + tenantLeases = await LeaseService.GetLeasesByTenantIdAsync(Id); } private void EditTenant() diff --git a/Aquiis.SimpleStart/Infrastructure/Data/ApplicationDbContext.cs b/Aquiis.SimpleStart/Infrastructure/Data/ApplicationDbContext.cs index 2df1f79..5bc265c 100644 --- a/Aquiis.SimpleStart/Infrastructure/Data/ApplicationDbContext.cs +++ b/Aquiis.SimpleStart/Infrastructure/Data/ApplicationDbContext.cs @@ -13,6 +13,16 @@ public ApplicationDbContext(DbContextOptions options) { } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Suppress pending model changes warning - bidirectional Document-Invoice/Payment relationship issue + // TODO: Fix the Document-Invoice and Document-Payment bidirectional relationships properly + optionsBuilder.ConfigureWarnings(warnings => + warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + } + public DbSet Properties { get; set; } public DbSet Leases { get; set; } public DbSet LeaseOffers { get; set; } diff --git a/Aquiis.SimpleStart/Program.cs b/Aquiis.SimpleStart/Program.cs index 1040a59..0f7ec17 100644 --- a/Aquiis.SimpleStart/Program.cs +++ b/Aquiis.SimpleStart/Program.cs @@ -6,6 +6,7 @@ using Aquiis.SimpleStart.Infrastructure.Data; using Aquiis.SimpleStart.Features.PropertyManagement; using Aquiis.SimpleStart.Core.Constants; +using Aquiis.SimpleStart.Core.Interfaces; using Aquiis.SimpleStart.Application.Services; using Aquiis.SimpleStart.Application.Services.PdfGenerators; using Aquiis.SimpleStart.Shared.Services; @@ -148,13 +149,19 @@ builder.Services.AddScoped(); // New refactored service builder.Services.AddScoped(); // New refactored service builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service +builder.Services.AddScoped(); // New refactored service builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Concrete class for services that need it +builder.Services.AddScoped(sp => sp.GetRequiredService()); // Interface alias builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); // Existing PDF service builder.Services.AddScoped(); // Workflow services diff --git a/Aquiis.SimpleStart/Shared/Components/LeaseRenewalWidget.razor b/Aquiis.SimpleStart/Shared/Components/LeaseRenewalWidget.razor index 78050b3..cfad360 100644 --- a/Aquiis.SimpleStart/Shared/Components/LeaseRenewalWidget.razor +++ b/Aquiis.SimpleStart/Shared/Components/LeaseRenewalWidget.razor @@ -1,6 +1,6 @@ @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.SimpleStart.Core.Entities -@inject PropertyManagementService PropertyManagementService +@inject LeaseService LeaseService @rendermode InteractiveServer
@@ -117,7 +117,7 @@ try { isLoading = true; - var allLeases = await PropertyManagementService.GetLeasesAsync(); + var allLeases = await LeaseService.GetAllAsync(); var today = DateTime.Today; expiringLeases = allLeases diff --git a/Aquiis.SimpleStart/Shared/Components/Pages/Home.razor b/Aquiis.SimpleStart/Shared/Components/Pages/Home.razor index 7ddfdb6..bebad19 100644 --- a/Aquiis.SimpleStart/Shared/Components/Pages/Home.razor +++ b/Aquiis.SimpleStart/Shared/Components/Pages/Home.razor @@ -9,7 +9,11 @@ @using Aquiis.SimpleStart.Shared.Components @inject NavigationManager NavigationManager -@inject PropertyManagementService PropertyManagementService +@inject PropertyService PropertyService +@inject TenantService TenantService +@inject LeaseService LeaseService +@inject MaintenanceService MaintenanceService +@inject InvoiceService InvoiceService @inject UserContextService UserContextService @inject ApplicationDbContext DbContext @@ -379,16 +383,16 @@ return; // Load summary counts - var allProperties = await PropertyManagementService.GetPropertiesAsync(); + var allProperties = await PropertyService.GetAllAsync(); properties = allProperties.Where(p => !p.IsDeleted).ToList(); totalProperties = properties.Count; availableProperties = properties.Count(p => p.IsAvailable); - var allTenants = await PropertyManagementService.GetTenantsAsync(); + var allTenants = await TenantService.GetAllAsync(); tenants = allTenants.Where(t => !t.IsDeleted).ToList(); totalTenants = tenants.Count; - var allLeases = await PropertyManagementService.GetLeasesAsync(); + var allLeases = await LeaseService.GetAllAsync(); leases = allLeases.Where(l => !l.IsDeleted).ToList(); activeLeases = leases.Count(l => l.Status == "Active"); @@ -406,7 +410,7 @@ .ToList(); // Load open maintenance requests - var allMaintenanceRequests = await PropertyManagementService.GetMaintenanceRequestsAsync(); + var allMaintenanceRequests = await MaintenanceService.GetAllAsync(); openMaintenanceRequests = allMaintenanceRequests .Where(m => m.OrganizationId == organizationId && m.Status != "Completed" && m.Status != "Cancelled") .OrderByDescending(m => m.Priority == "Urgent" ? 1 : m.Priority == "High" ? 2 : 3) @@ -415,7 +419,7 @@ .ToList(); // Load recent invoices - var allInvoices = await PropertyManagementService.GetInvoicesAsync(); + var allInvoices = await InvoiceService.GetAllAsync(); recentInvoices = allInvoices .Where(i => i.Status != "Paid" && i.Status != "Cancelled") .OrderByDescending(i => i.InvoicedOn) diff --git a/Aquiis.SimpleStart/Shared/Services/DocumentService.cs b/Aquiis.SimpleStart/Shared/Services/DocumentService.cs deleted file mode 100644 index ab7214b..0000000 --- a/Aquiis.SimpleStart/Shared/Services/DocumentService.cs +++ /dev/null @@ -1,46 +0,0 @@ - -using Aquiis.SimpleStart.Core.Entities; -using Aquiis.SimpleStart.Infrastructure.Data; -using Aquiis.SimpleStart.Application.Services.PdfGenerators; -using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; - -namespace Aquiis.SimpleStart.Shared.Services -{ - public class DocumentService - { - private readonly ApplicationDbContext _dbContext; - - public DocumentService(ApplicationDbContext dbContext) - { - _dbContext = dbContext; - } - - public async Task UploadDocumentAsync(Document document) - { - _dbContext.Documents.Add(document); - await _dbContext.SaveChangesAsync(); - return document; - } - - public async Task DeleteDocumentAsync(Guid documentId) - { - var document = await _dbContext.Documents.FindAsync(documentId); - if (document != null) - { - _dbContext.Documents.Remove(document); - await _dbContext.SaveChangesAsync(); - } - } - - public async Task GetDocumentByIdAsync(Guid documentId) - { - return await _dbContext.Documents.FindAsync(documentId); - } - - public async Task GenerateLeaseDocumentAsync(Lease lease) - { - // Implementation for generating lease document - return await LeasePdfGenerator.GenerateLeasePdf(lease); - } - } -} \ No newline at end of file From b10855d78a00316890a4617809366ebdf9af6120 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 18:20:48 -0600 Subject: [PATCH 6/9] Phase 1.C and 1.D - Migrate razor components and test complete. --- .../GuidValidationAttributeTests.cs | 59 ++++++------ .../MaintenanceServiceTests.cs | 24 ++++- .../.runsettings | 0 .../Aquiis.SimpleStart.UI.Tests.csproj | 0 .../NewSetupUITests.cs | 16 +--- .../README.md | 0 .../UnitTest1.cs.bak | 0 .../run-tests-debug.sh | 0 .../run-tests.sh | 0 Aquiis.Tests/ApplicationTests.cs | 95 ------------------- Aquiis.sln | 2 +- 11 files changed, 55 insertions(+), 141 deletions(-) rename {Aquiis.Tests => Aquiis.SimpleStart.Tests}/Core/Validation/GuidValidationAttributeTests.cs (82%) rename {Aquiis.Tests => Aquiis.SimpleStart.UI.Tests}/.runsettings (100%) rename Aquiis.Tests/Aquiis.Tests.csproj => Aquiis.SimpleStart.UI.Tests/Aquiis.SimpleStart.UI.Tests.csproj (100%) rename Aquiis.Tests/AccountTests.cs => Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs (97%) rename {Aquiis.Tests => Aquiis.SimpleStart.UI.Tests}/README.md (100%) rename {Aquiis.Tests => Aquiis.SimpleStart.UI.Tests}/UnitTest1.cs.bak (100%) rename {Aquiis.Tests => Aquiis.SimpleStart.UI.Tests}/run-tests-debug.sh (100%) rename {Aquiis.Tests => Aquiis.SimpleStart.UI.Tests}/run-tests.sh (100%) delete mode 100644 Aquiis.Tests/ApplicationTests.cs diff --git a/Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs b/Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs similarity index 82% rename from Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs rename to Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs index ddc5e42..0086347 100644 --- a/Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs +++ b/Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs @@ -1,12 +1,13 @@ using Aquiis.SimpleStart.Core.Validation; +using System; using System.ComponentModel.DataAnnotations; -using NUnit.Framework; +using Xunit; -namespace Aquiis.Tests.Core.Validation; +namespace Aquiis.SimpleStart.Tests.Core.Validation; public class RequiredGuidAttributeTests { - [Test] + [Fact] public void RequiredGuid_GuidEmpty_ReturnsFalse() { // Arrange @@ -17,10 +18,10 @@ public void RequiredGuid_GuidEmpty_ReturnsFalse() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.False); + Assert.False(result); } - [Test] + [Fact] public void RequiredGuid_ValidGuid_ReturnsTrue() { // Arrange @@ -31,10 +32,10 @@ public void RequiredGuid_ValidGuid_ReturnsTrue() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.True); + Assert.True(result); } - [Test] + [Fact] public void RequiredGuid_Null_ReturnsFalse() { // Arrange @@ -45,10 +46,10 @@ public void RequiredGuid_Null_ReturnsFalse() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.False); + Assert.False(result); } - [Test] + [Fact] public void RequiredGuid_WithContext_GuidEmpty_ReturnsValidationError() { // Arrange @@ -60,11 +61,11 @@ public void RequiredGuid_WithContext_GuidEmpty_ReturnsValidationError() var result = attribute.GetValidationResult(Guid.Empty, context); // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.Not.EqualTo(ValidationResult.Success)); + Assert.NotNull(result); + Assert.NotEqual(ValidationResult.Success, result); } - [Test] + [Fact] public void RequiredGuid_WithContext_ValidGuid_ReturnsSuccess() { // Arrange @@ -76,10 +77,10 @@ public void RequiredGuid_WithContext_ValidGuid_ReturnsSuccess() var result = attribute.GetValidationResult(model.Id, context); // Assert - Assert.That(result, Is.EqualTo(ValidationResult.Success)); + Assert.Equal(ValidationResult.Success, result); } - [Test] + [Fact] public void RequiredGuid_CustomErrorMessage_UsesCustomMessage() { // Arrange @@ -92,8 +93,8 @@ public void RequiredGuid_CustomErrorMessage_UsesCustomMessage() var result = attribute.GetValidationResult(Guid.Empty, context); // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result!.ErrorMessage, Is.EqualTo(customMessage)); + Assert.NotNull(result); + Assert.Equal(customMessage, result!.ErrorMessage); } private class TestModel @@ -104,7 +105,7 @@ private class TestModel public class OptionalGuidAttributeTests { - [Test] + [Fact] public void OptionalGuid_Null_ReturnsTrue() { // Arrange @@ -115,10 +116,10 @@ public void OptionalGuid_Null_ReturnsTrue() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.True); + Assert.True(result); } - [Test] + [Fact] public void OptionalGuid_GuidEmpty_ReturnsFalse() { // Arrange @@ -129,10 +130,10 @@ public void OptionalGuid_GuidEmpty_ReturnsFalse() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.False); + Assert.False(result); } - [Test] + [Fact] public void OptionalGuid_ValidGuid_ReturnsTrue() { // Arrange @@ -143,10 +144,10 @@ public void OptionalGuid_ValidGuid_ReturnsTrue() var result = attribute.IsValid(value); // Assert - Assert.That(result, Is.True); + Assert.True(result); } - [Test] + [Fact] public void OptionalGuid_WithContext_Null_ReturnsSuccess() { // Arrange @@ -158,10 +159,10 @@ public void OptionalGuid_WithContext_Null_ReturnsSuccess() var result = attribute.GetValidationResult(null, context); // Assert - Assert.That(result, Is.EqualTo(ValidationResult.Success)); + Assert.Equal(ValidationResult.Success, result); } - [Test] + [Fact] public void OptionalGuid_WithContext_GuidEmpty_ReturnsValidationError() { // Arrange @@ -173,11 +174,11 @@ public void OptionalGuid_WithContext_GuidEmpty_ReturnsValidationError() var result = attribute.GetValidationResult(Guid.Empty, context); // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.Not.EqualTo(ValidationResult.Success)); + Assert.NotNull(result); + Assert.NotEqual(ValidationResult.Success, result); } - [Test] + [Fact] public void OptionalGuid_WithContext_ValidGuid_ReturnsSuccess() { // Arrange @@ -190,7 +191,7 @@ public void OptionalGuid_WithContext_ValidGuid_ReturnsSuccess() var result = attribute.GetValidationResult(validGuid, context); // Assert - Assert.That(result, Is.EqualTo(ValidationResult.Success)); + Assert.Equal(ValidationResult.Success, result); } private class TestModel diff --git a/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs index 3b0ea46..150722b 100644 --- a/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs +++ b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs @@ -385,18 +385,36 @@ public async Task CreateAsync_InvalidPropertyOrganization_ThrowsException() CreatedOn = DateTime.UtcNow }; _context.Organizations.Add(otherOrg); + + var otherProperty = new Property + { + Id = Guid.NewGuid(), + OrganizationId = otherOrg.Id, + Address = "999 Other St", + City = "Other City", + State = "OT", + ZipCode = "99999", + PropertyType = "Apartment", + Bedrooms = 2, + Bathrooms = 1, + SquareFeet = 900, + IsAvailable = true, + CreatedBy = _testUser.Id, + CreatedOn = DateTime.UtcNow + }; + _context.Properties.Add(otherProperty); await _context.SaveChangesAsync(); var maintenanceRequest = new MaintenanceRequest { - PropertyId = _testProperty.Id, + PropertyId = otherProperty.Id, // Property from different org Title = "Test Request", Description = "Test Description", RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing, Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium, Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted, - RequestedOn = DateTime.Today, - OrganizationId = otherOrg.Id // Different org than property + RequestedOn = DateTime.Today + // OrganizationId will be auto-set from user context, which won't match property's org }; // Act & Assert diff --git a/Aquiis.Tests/.runsettings b/Aquiis.SimpleStart.UI.Tests/.runsettings similarity index 100% rename from Aquiis.Tests/.runsettings rename to Aquiis.SimpleStart.UI.Tests/.runsettings diff --git a/Aquiis.Tests/Aquiis.Tests.csproj b/Aquiis.SimpleStart.UI.Tests/Aquiis.SimpleStart.UI.Tests.csproj similarity index 100% rename from Aquiis.Tests/Aquiis.Tests.csproj rename to Aquiis.SimpleStart.UI.Tests/Aquiis.SimpleStart.UI.Tests.csproj diff --git a/Aquiis.Tests/AccountTests.cs b/Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs similarity index 97% rename from Aquiis.Tests/AccountTests.cs rename to Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs index 9c492ed..3fb8327 100644 --- a/Aquiis.Tests/AccountTests.cs +++ b/Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs @@ -1,15 +1,12 @@ using Microsoft.Playwright.NUnit; using Microsoft.Playwright; -using NUnit.Framework; -using System.IO; -using System.Threading.Tasks; -namespace Aquiis.Tests; +namespace Aquiis.SimpleStart.UI.Tests; [Parallelizable(ParallelScope.Self)] [TestFixture] -public class AccountManagementTests : PageTest +public class NewSetupUITests : PageTest { private const string BaseUrl = "http://localhost:5197"; @@ -65,9 +62,7 @@ public async Task CreateNewAccount() await Page.GetByRole(AriaRole.Heading, new() { Name = "Property Management Dashboard", Exact= true }).ClickAsync(); - //Keep browser open for review/recording - // if (KeepBrowserOpenSeconds > 0) - // await Task.Delay(KeepBrowserOpenSeconds * 1000); + } [Test, Order(2)] @@ -221,9 +216,6 @@ public async Task ScheduleAndCompleteTour() await Page.GetByText("Interested", new() { Exact = true }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter value" }).Nth(1).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter value" }).Nth(1).FillAsync("None"); - await Page.GetByRole(AriaRole.Button, new() { Name = " Save Progress" }).ClickAsync(); await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); @@ -236,8 +228,6 @@ public async Task ScheduleAndCompleteTour() await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); - //await Task.Delay(5000); // Wait for PDF generation - var page1 = await Page.RunAndWaitForPopupAsync(async () => { await Page.GetByRole(AriaRole.Button, new() { Name = " View PDF" }).ClickAsync(); diff --git a/Aquiis.Tests/README.md b/Aquiis.SimpleStart.UI.Tests/README.md similarity index 100% rename from Aquiis.Tests/README.md rename to Aquiis.SimpleStart.UI.Tests/README.md diff --git a/Aquiis.Tests/UnitTest1.cs.bak b/Aquiis.SimpleStart.UI.Tests/UnitTest1.cs.bak similarity index 100% rename from Aquiis.Tests/UnitTest1.cs.bak rename to Aquiis.SimpleStart.UI.Tests/UnitTest1.cs.bak diff --git a/Aquiis.Tests/run-tests-debug.sh b/Aquiis.SimpleStart.UI.Tests/run-tests-debug.sh similarity index 100% rename from Aquiis.Tests/run-tests-debug.sh rename to Aquiis.SimpleStart.UI.Tests/run-tests-debug.sh diff --git a/Aquiis.Tests/run-tests.sh b/Aquiis.SimpleStart.UI.Tests/run-tests.sh similarity index 100% rename from Aquiis.Tests/run-tests.sh rename to Aquiis.SimpleStart.UI.Tests/run-tests.sh diff --git a/Aquiis.Tests/ApplicationTests.cs b/Aquiis.Tests/ApplicationTests.cs deleted file mode 100644 index 9db0bb6..0000000 --- a/Aquiis.Tests/ApplicationTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Microsoft.Playwright.NUnit; -using Microsoft.Playwright; - -namespace Aquiis.Tests; - -[Parallelizable(ParallelScope.Self)] -[TestFixture] -public class PropertyManagementTests : PageTest -{ - private const string BaseUrl = "http://localhost:5197"; - private const int KeepBrowserOpenSeconds = 30; // Set to 0 to close immediately - - public override BrowserNewContextOptions ContextOptions() - { - return new BrowserNewContextOptions - { - IgnoreHTTPSErrors = true, - BaseURL = BaseUrl, - RecordVideoDir = Path.Combine(Directory.GetCurrentDirectory(), "test-videos"), - RecordVideoSize = new RecordVideoSize { Width = 1280, Height = 720 } - }; - } - - [Test] - public async Task CanAddProperty() - { - await Page.GotoAsync("http://localhost:5197/"); - await Page.GetByRole(AriaRole.Button, new() { Name = "Sign In" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).FillAsync("Owner1@aquiis.com"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).PressAsync("Tab"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Password" }).FillAsync("Today123"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Password" }).PressAsync("Enter"); - - // Wait for login to complete - await Page.WaitForSelectorAsync("text=Dashboard"); - - await Page.GetByRole(AriaRole.Link, new() { Name = "Properties" }).ClickAsync(); - await Page.GetByRole(AriaRole.Button, new() { Name = "Add Property" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter property address" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter property address" }).FillAsync("3535 Delaney"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter property address" }).PressAsync("Tab"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "e.g., Apt 2B, Unit" }).PressAsync("Tab"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "City" }).FillAsync("Houston"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "City" }).PressAsync("Tab"); - await Page.Locator("select[name=\"propertyModel.State\"]").SelectOptionAsync(new[] { "TX" }); - await Page.Locator("select[name=\"propertyModel.State\"]").PressAsync("Tab"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "#####-####" }).FillAsync("77066"); - await Page.Locator("select[name=\"propertyModel.PropertyType\"]").SelectOptionAsync(new[] { "House" }); - await Page.GetByPlaceholder("0.00").ClickAsync(); - await Page.GetByPlaceholder("0.00").FillAsync("1500"); - await Page.Locator("input[name=\"propertyModel.Bedrooms\"]").ClickAsync(); - await Page.Locator("input[name=\"propertyModel.Bedrooms\"]").FillAsync("4"); - await Page.Locator("input[name=\"propertyModel.Bedrooms\"]").PressAsync("Tab"); - await Page.Locator("input[name=\"propertyModel.Bathrooms\"]").FillAsync("4.5"); - await Page.Locator("input[name=\"propertyModel.Bathrooms\"]").PressAsync("Tab"); - await Page.Locator("input[name=\"propertyModel.SquareFeet\"]").FillAsync("1700"); - await Page.Locator("input[name=\"propertyModel.SquareFeet\"]").PressAsync("Tab"); - await Page.GetByRole(AriaRole.Button, new() { Name = "Create Property" }).ClickAsync(); - - // Verify property was created successfully - await Page.WaitForSelectorAsync("h1:has-text('Properties')"); - await Expect(Page.GetByText("3535 Delaney").First).ToBeVisibleAsync(); - } - - [Test] - public async Task CanRemoveProperty() - { - await Page.GotoAsync("http://localhost:5197/"); - await Page.GetByRole(AriaRole.Button, new() { Name = "Sign In" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).ClickAsync(); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).FillAsync("owner1@aquiis.com"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Email" }).PressAsync("Tab"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Password" }).FillAsync("Today123"); - await Page.GetByRole(AriaRole.Textbox, new() { Name = "Password" }).PressAsync("Enter"); - - // Wait for login to complete - await Page.WaitForSelectorAsync("text=Dashboard"); - - await Page.GetByRole(AriaRole.Link, new() { Name = "Properties" }).ClickAsync(); - - // Wait for properties page to load - await Page.WaitForSelectorAsync("h1:has-text('Properties')"); - - // Find the property "3535 Delaney" and click its delete button - await Page.Locator("[id^='property-']", new() { HasText = "3535 Delaney" }) - .GetByTitle("Delete").First.ClickAsync(); - - // Confirm deletion - //await Page.GetByRole(AriaRole.Button, new() { Name = "Delete", Exact = true }).ClickAsync(); - - // Verify property was deleted - //await Expect(Page.GetByText("3535 Delaney")).Not.ToBeVisibleAsync(); - } -} diff --git a/Aquiis.sln b/Aquiis.sln index dc6c83a..1f46f04 100644 --- a/Aquiis.sln +++ b/Aquiis.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}") = "Aquiis.SimpleStart", "Aquiis.SimpleStart\Aquiis.SimpleStart.csproj", "{C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.Tests", "Aquiis.Tests\Aquiis.Tests.csproj", "{81401A51-BBA1-4B6B-8771-9C26A7B5356E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.Tests", "Aquiis.SimpleStart.UI.Tests\Aquiis.SimpleStart.UI.Tests.csproj", "{81401A51-BBA1-4B6B-8771-9C26A7B5356E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.SimpleStart.Tests", "Aquiis.SimpleStart.Tests\Aquiis.SimpleStart.Tests.csproj", "{D1111111-1111-4111-8111-111111111111}" EndProject From af98892843317e0925a4d59a97ef1e68b181f63c Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 18:31:18 -0600 Subject: [PATCH 7/9] Configure all projects for solution file. --- Aquiis.sln | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Aquiis.sln b/Aquiis.sln index 1f46f04..995674f 100644 --- a/Aquiis.sln +++ b/Aquiis.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}") = "Aquiis.SimpleStart", "Aquiis.SimpleStart\Aquiis.SimpleStart.csproj", "{C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.Tests", "Aquiis.SimpleStart.UI.Tests\Aquiis.SimpleStart.UI.Tests.csproj", "{81401A51-BBA1-4B6B-8771-9C26A7B5356E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.SimpleStart.UI.Tests", "Aquiis.SimpleStart.UI.Tests\Aquiis.SimpleStart.UI.Tests.csproj", "{81401A51-BBA1-4B6B-8771-9C26A7B5356E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aquiis.SimpleStart.Tests", "Aquiis.SimpleStart.Tests\Aquiis.SimpleStart.Tests.csproj", "{D1111111-1111-4111-8111-111111111111}" EndProject @@ -31,6 +31,18 @@ Global {C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}.Release|x64.Build.0 = Release|x64 {C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}.Release|x86.ActiveCfg = Release|x86 {C69B6EFE-BB20-41DE-8CBA-044207EBDCE1}.Release|x86.Build.0 = Release|x86 + {D1111111-1111-4111-8111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Debug|x64.Build.0 = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Debug|x86.Build.0 = Debug|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|x64.ActiveCfg = Release|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|x64.Build.0 = Release|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|x86.ActiveCfg = Release|Any CPU + {D1111111-1111-4111-8111-111111111111}.Release|x86.Build.0 = Release|Any CPU {81401A51-BBA1-4B6B-8771-9C26A7B5356E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {81401A51-BBA1-4B6B-8771-9C26A7B5356E}.Debug|Any CPU.Build.0 = Debug|Any CPU {81401A51-BBA1-4B6B-8771-9C26A7B5356E}.Debug|x64.ActiveCfg = Debug|Any CPU From 47b359f87c75c7eed880f0535a4128417a94e856 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 20:29:13 -0600 Subject: [PATCH 8/9] See REVISIONS.md for details --- .../PaymentServiceTests.cs | 18 +++++++++--------- .../Application/Services/PaymentService.cs | 14 +++++++------- .../Core/Constants/ApplicationConstants.cs | 4 ++++ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs index 90c7aba..57ed152 100644 --- a/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs +++ b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs @@ -576,7 +576,7 @@ public async Task DeleteAsync_UpdatesInvoiceStatus() InvoiceId = _testInvoiceId, Amount = 1500, PaidOn = DateTime.Today, - PaymentMethod = "Check", + PaymentMethod = ApplicationConstants.PaymentMethods.Check, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -606,7 +606,7 @@ public async Task CalculateTotalPaymentsAsync_ReturnsCorrectTotal() InvoiceId = _testInvoiceId, Amount = 500, PaidOn = DateTime.Today, - PaymentMethod = "Check", + PaymentMethod = ApplicationConstants.PaymentMethods.Check, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -618,7 +618,7 @@ public async Task CalculateTotalPaymentsAsync_ReturnsCorrectTotal() InvoiceId = _testInvoiceId, Amount = 750, PaidOn = DateTime.Today, - PaymentMethod = "Cash", + PaymentMethod = ApplicationConstants.PaymentMethods.Cash, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -641,7 +641,7 @@ public async Task CalculateTotalPaymentsAsync_WithDateRange_ReturnsFilteredTotal InvoiceId = _testInvoiceId, Amount = 500, PaidOn = DateTime.Today.AddMonths(-2), - PaymentMethod = "Check", + PaymentMethod = ApplicationConstants.PaymentMethods.Check, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -653,7 +653,7 @@ public async Task CalculateTotalPaymentsAsync_WithDateRange_ReturnsFilteredTotal InvoiceId = _testInvoiceId, Amount = 750, PaidOn = DateTime.Today, - PaymentMethod = "Cash", + PaymentMethod = ApplicationConstants.PaymentMethods.Cash, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -690,7 +690,7 @@ public async Task GetPaymentSummaryByMethodAsync_ReturnsCorrectSummary() InvoiceId = _testInvoiceId, Amount = 300, PaidOn = DateTime.Today, - PaymentMethod = "Check", + PaymentMethod = ApplicationConstants.PaymentMethods.Check, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -702,7 +702,7 @@ public async Task GetPaymentSummaryByMethodAsync_ReturnsCorrectSummary() InvoiceId = _testInvoiceId, Amount = 700, PaidOn = DateTime.Today, - PaymentMethod = "Cash", + PaymentMethod = ApplicationConstants.PaymentMethods.Cash, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -727,7 +727,7 @@ public async Task GetTotalPaidForInvoiceAsync_ReturnsCorrectTotal() InvoiceId = _testInvoiceId, Amount = 500, PaidOn = DateTime.Today, - PaymentMethod = "Check", + PaymentMethod = ApplicationConstants.PaymentMethods.OnlinePayment, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -739,7 +739,7 @@ public async Task GetTotalPaidForInvoiceAsync_ReturnsCorrectTotal() InvoiceId = _testInvoiceId, Amount = 750, PaidOn = DateTime.Today, - PaymentMethod = "Cash", + PaymentMethod = ApplicationConstants.PaymentMethods.Cash, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; diff --git a/Aquiis.SimpleStart/Application/Services/PaymentService.cs b/Aquiis.SimpleStart/Application/Services/PaymentService.cs index 47219c6..4f83899 100644 --- a/Aquiis.SimpleStart/Application/Services/PaymentService.cs +++ b/Aquiis.SimpleStart/Application/Services/PaymentService.cs @@ -362,34 +362,34 @@ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId) // Update invoice status based on payment if (totalPaid >= totalDue) { - invoice.Status = "Paid"; + invoice.Status = ApplicationConstants.InvoiceStatuses.Paid; invoice.PaidOn = invoice.Payments .Where(p => !p.IsDeleted) .OrderByDescending(p => p.PaidOn) .FirstOrDefault()?.PaidOn ?? DateTime.UtcNow; } - else if (totalPaid > 0 && invoice.Status != "Cancelled") + else if (totalPaid > 0 && invoice.Status != ApplicationConstants.InvoiceStatuses.Cancelled) { // Invoice is partially paid if (invoice.DueOn < DateTime.Today) { - invoice.Status = "Overdue"; + invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue; } else { - invoice.Status = "Pending"; + invoice.Status = ApplicationConstants.InvoiceStatuses.Pending; } } - else if (invoice.Status != "Cancelled") + else if (invoice.Status != ApplicationConstants.InvoiceStatuses.Cancelled) { // No payments if (invoice.DueOn < DateTime.Today) { - invoice.Status = "Overdue"; + invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue; } else { - invoice.Status = "Pending"; + invoice.Status = ApplicationConstants.InvoiceStatuses.Pending; } } diff --git a/Aquiis.SimpleStart/Core/Constants/ApplicationConstants.cs b/Aquiis.SimpleStart/Core/Constants/ApplicationConstants.cs index 8bf0db2..4fb9eb2 100644 --- a/Aquiis.SimpleStart/Core/Constants/ApplicationConstants.cs +++ b/Aquiis.SimpleStart/Core/Constants/ApplicationConstants.cs @@ -152,6 +152,8 @@ public static class PaymentMethods public const string CreditCard = "Credit Card"; public const string BankTransfer = "Bank Transfer"; public const string CryptoCurrency = "Crypto Currency"; + public const string Cash = "Cash"; + public const string Check = "Check"; public const string Other = "Other"; public static IReadOnlyList AllPaymentMethods { get; } = new List @@ -161,6 +163,8 @@ public static class PaymentMethods CreditCard, BankTransfer, CryptoCurrency, + Cash, + Check, Other }; } From 5e07ba8c7372fc488354aa065d421e8ca02eaf9e Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sun, 28 Dec 2025 20:48:59 -0600 Subject: [PATCH 9/9] Update test command to include Release configuration --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f1c7ee..ed4926f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,4 +26,4 @@ jobs: run: dotnet build Aquiis.sln --no-restore --configuration Release - name: Run focused tests - run: dotnet test Aquiis.SimpleStart.Tests/Aquiis.SimpleStart.Tests.csproj --no-build --verbosity normal + run: dotnet test Aquiis.SimpleStart.Tests/Aquiis.SimpleStart.Tests.csproj --no-build --configuration Release --verbosity normal