diff --git a/Marco.Pms.Model/Dtos/PaymentGetway/PaymentVerificationRequest.cs b/Marco.Pms.Model/Dtos/PaymentGetway/PaymentVerificationRequest.cs index f0bfe49..dd8ed49 100644 --- a/Marco.Pms.Model/Dtos/PaymentGetway/PaymentVerificationRequest.cs +++ b/Marco.Pms.Model/Dtos/PaymentGetway/PaymentVerificationRequest.cs @@ -2,8 +2,10 @@ { public class PaymentVerificationRequest { - public string? OrderId { get; set; } - public string? PaymentId { get; set; } - public string? Signature { get; set; } + public required Guid TenantEnquireId { get; set; } + public required Guid PlanId { get; set; } + public required string OrderId { get; set; } + public required string PaymentId { get; set; } + public required string Signature { get; set; } } } diff --git a/Marco.Pms.Model/TenantModels/TenantSubscriptions.cs b/Marco.Pms.Model/TenantModels/TenantSubscriptions.cs index 847b2b2..b9a2f05 100644 --- a/Marco.Pms.Model/TenantModels/TenantSubscriptions.cs +++ b/Marco.Pms.Model/TenantModels/TenantSubscriptions.cs @@ -1,5 +1,6 @@ using Marco.Pms.Model.Employees; using Marco.Pms.Model.Master; +using Marco.Pms.Model.PaymentGetway; using Marco.Pms.Model.Utilities; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using System.ComponentModel.DataAnnotations.Schema; @@ -32,6 +33,11 @@ namespace Marco.Pms.Model.TenantModels public DateTime? CancellationDate { get; set; } public bool AutoRenew { get; set; } = true; public bool IsCancelled { get; set; } = false; + public Guid PaymentDetailId { get; set; } + + [ForeignKey("PaymentDetailId")] + [ValidateNever] + public PaymentDetail? PaymentDetail { get; set; } public DateTime CreatedAt { get; set; } public DateTime? UpdateAt { get; set; } public Guid CreatedById { get; set; } diff --git a/Marco.Pms.Model/ViewModels/PaymentGetway/RazorpayPaymentDetails.cs b/Marco.Pms.Model/ViewModels/PaymentGetway/RazorpayPaymentDetails.cs index 85b12f1..4bb6db2 100644 --- a/Marco.Pms.Model/ViewModels/PaymentGetway/RazorpayPaymentDetails.cs +++ b/Marco.Pms.Model/ViewModels/PaymentGetway/RazorpayPaymentDetails.cs @@ -2,6 +2,7 @@ { public class PaymentDetailsVM { + public Guid Id { get; set; } public RazorpayPaymentDetails? RazorpayPaymentDetails { get; set; } public RazorpayOrderDetails? RazorpayOrderDetails { get; set; } } diff --git a/Marco.Pms.Services/Controllers/PaymentController.cs b/Marco.Pms.Services/Controllers/PaymentController.cs index ee9dbfb..9752f42 100644 --- a/Marco.Pms.Services/Controllers/PaymentController.cs +++ b/Marco.Pms.Services/Controllers/PaymentController.cs @@ -14,15 +14,17 @@ namespace Marco.Pms.Services.Controllers private readonly UserHelper _userHelper; private readonly ILoggingService _logger; private readonly IRazorpayService _razorpayService; + private readonly ITenantService _tenantService; private readonly Guid tenantId; private readonly Guid organizaionId; - public PaymentController(UserHelper userHelper, ILoggingService logger, IRazorpayService razorpayService) + public PaymentController(UserHelper userHelper, ILoggingService logger, IRazorpayService razorpayService, ITenantService tenantService) { _userHelper = userHelper; _logger = logger; _razorpayService = razorpayService; tenantId = userHelper.GetTenantId(); organizaionId = userHelper.GetCurrentOrganizationId(); + _tenantService = tenantService; } [HttpPost("create-order")] @@ -77,6 +79,7 @@ namespace Marco.Pms.Services.Controllers // Fetch complete payment details from Razorpay including card details var response = await _razorpayService.GetPaymentDetails(request.PaymentId); + var tenant = await _tenantService.CreateTenantAsync(request.TenantEnquireId, response.Id, request.PlanId); _logger.LogInfo("Invoice generated and saved for OrderId: {OrderId}", request.OrderId); diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index 58d3aa8..b42e289 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -923,6 +923,15 @@ namespace Marco.Pms.Services.Controllers // Create db context asynchronously for optimized resource use await using var context = await _dbContextFactory.CreateDbContextAsync(); + // 2. --- VALIDATION --- + // Check if a user with the same email already exists. + var existingUser = await _userManager.FindByEmailAsync(model.Email); + if (existingUser != null) + { + _logger.LogWarning("Tenant creation failed for email {Email}: an application user with this email already exists.", model.Email); + return StatusCode(409, ApiResponse.ErrorResponse("Tenant cannot be created", "A user with the specified email already exists.", 409)); + } + // Map DTO to domain model and assign new Guid var tenantEnquire = _mapper.Map(model); tenantEnquire.Id = Guid.NewGuid(); diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 92bd03b..44da1a4 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -185,6 +185,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); #endregion #region Helpers diff --git a/Marco.Pms.Services/Service/RazorpayService.cs b/Marco.Pms.Services/Service/RazorpayService.cs index b73a315..fa6cc98 100644 --- a/Marco.Pms.Services/Service/RazorpayService.cs +++ b/Marco.Pms.Services/Service/RazorpayService.cs @@ -91,6 +91,8 @@ namespace Marco.Pms.Services.Service // Extract customer name from notes or fetch from customer API string customerName = ExtractCustomerName(payment); + Guid paymentDetailsId = Guid.NewGuid(); + // Map to custom model with all details var paymentDetails = new RazorpayPaymentDetails { @@ -143,6 +145,7 @@ namespace Marco.Pms.Services.Service var response = new PaymentDetailsVM { + Id = paymentDetailsId, RazorpayPaymentDetails = paymentDetails, RazorpayOrderDetails = razorpayOrderDetails }; @@ -153,7 +156,7 @@ namespace Marco.Pms.Services.Service var paymentDetail = new PaymentDetail { - Id = Guid.NewGuid(), + Id = paymentDetailsId, PaymentId = paymentDetails.PaymentId ?? "", OrderId = paymentDetails.OrderId ?? "", Status = paymentDetails.Status ?? "", diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/ITenantService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/ITenantService.cs new file mode 100644 index 0000000..2070877 --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/ITenantService.cs @@ -0,0 +1,9 @@ +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface ITenantService + { + Task> CreateTenantAsync(Guid enquireId, Guid paymentDetailId, Guid planId); + } +} diff --git a/Marco.Pms.Services/Service/TenantService.cs b/Marco.Pms.Services/Service/TenantService.cs new file mode 100644 index 0000000..03beea9 --- /dev/null +++ b/Marco.Pms.Services/Service/TenantService.cs @@ -0,0 +1,682 @@ +using AutoMapper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Helpers.Utility; +using Marco.Pms.Model.Dtos.Tenant; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.OrganizationModel; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Roles; +using Marco.Pms.Model.TenantModels; +using Marco.Pms.Model.TenantModels.MongoDBModel; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Tenant; +using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Helpers; +using MarcoBMS.Services.Service; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using System.Net; + +namespace Marco.Pms.Services.Service +{ + public class TenantService : ITenantService + { + private readonly IDbContextFactory _dbContextFactory; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILoggingService _logger; + private readonly UserManager _userManager; + private readonly IMapper _mapper; + private readonly UserHelper _userHelper; + private readonly FeatureDetailsHelper _featureDetailsHelper; + + private readonly static Guid projectActiveStatus = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); + private readonly static Guid projectInProgressStatus = Guid.Parse("cdad86aa-8a56-4ff4-b633-9c629057dfef"); + private readonly static Guid projectOnHoldStatus = Guid.Parse("603e994b-a27f-4e5d-a251-f3d69b0498ba"); + private readonly static Guid projectInActiveStatus = Guid.Parse("ef1c356e-0fe0-42df-a5d3-8daee355492d"); + private readonly static Guid projectCompletedStatus = Guid.Parse("33deaef9-9af1-4f2a-b443-681ea0d04f81"); + + private readonly static Guid tenantActiveStatus = Guid.Parse("62b05792-5115-4f99-8ff5-e8374859b191"); + private readonly static Guid activePlanStatus = Guid.Parse("cd3a68ea-41fd-42f0-bd0c-c871c7337727"); + private readonly static Guid EmployeeFeatureId = Guid.Parse("81ab8a87-8ccd-4015-a917-0627cee6a100"); + private readonly static string AdminRoleName = "Admin"; + public TenantService(IDbContextFactory dbContextFactory, + IServiceScopeFactory serviceScopeFactory, + ILoggingService logger, + UserManager userManager, + IMapper mapper, + UserHelper userHelper, + FeatureDetailsHelper featureDetailsHelper) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _userHelper = userHelper ?? throw new ArgumentNullException(nameof(userHelper)); + _featureDetailsHelper = featureDetailsHelper ?? throw new ArgumentNullException(nameof(featureDetailsHelper)); + } + + public async Task> CreateTenantAsync(Guid enquireId, Guid paymentDetailId, Guid planId) + { + using var scope = _serviceScopeFactory.CreateScope(); + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + + var _configuration = scope.ServiceProvider.GetRequiredService(); + var _emailSender = scope.ServiceProvider.GetRequiredService(); + + var tenantEnquireTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.TenantEnquires.FirstOrDefaultAsync(te => te.Id == enquireId); + }); + var paymentDetailTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.PaymentDetails.FirstOrDefaultAsync(pd => pd.Id == paymentDetailId); + }); + var subscriptionPlanTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.SubscriptionPlanDetails.Include(sp => sp.Plan).FirstOrDefaultAsync(sp => sp.Id == planId); + }); + + await Task.WhenAll(tenantEnquireTask, paymentDetailTask, subscriptionPlanTask); + + var tenantEnquire = tenantEnquireTask.Result; + var paymentDetail = paymentDetailTask.Result; + var subscriptionPlan = subscriptionPlanTask.Result; + + if (tenantEnquire == null) + { + _logger.LogWarning("Tenant Enquire {TenantEnquireId} not found in database", enquireId); + return ApiResponse.ErrorResponse("Tenant Enquire not found", "Tenant Enquire not found", 404); + } + if (paymentDetail == null) + { + _logger.LogWarning("Payment Details {PaymentDetailsId} not found in database", paymentDetailId); + return ApiResponse.ErrorResponse("Payment Details not found", "Payment Details not found", 404); + } + if (subscriptionPlan == null) + { + _logger.LogWarning("Subscription plan {PlanId} not found in database", planId); + return ApiResponse.ErrorResponse("Subscription plan not found", "Subscription plan not found", 404); + } + + var existingUser = await _userManager.FindByEmailAsync(tenantEnquire.Email); + if (existingUser != null) + { + _logger.LogWarning("Tenant creation failed for email {Email}: an application user with this email already exists.", tenantEnquire.Email); + return ApiResponse.ErrorResponse("Tenant cannot be created", "A user with the specified email already exists.", 409); + } + + await using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + Guid employeeId = Guid.NewGuid(); + DateTime onBoardingDate = DateTime.UtcNow; + Guid tenantId = Guid.NewGuid(); + + // Get last SPRID and increment for new organization + var lastOrganization = await _context.Organizations.OrderByDescending(sp => sp.SPRID).FirstOrDefaultAsync(); + double lastSPRID = lastOrganization?.SPRID ?? 5400; + + // Map DTO to entity and set defaults + Organization organization = new Organization + { + Name = tenantEnquire.OrganizationName, + Email = tenantEnquire.Email, + ContactPerson = $"{tenantEnquire.FirstName} {tenantEnquire.LastName}", + Address = tenantEnquire.BillingAddress, + ContactNumber = tenantEnquire.ContactNumber, + SPRID = lastSPRID + 1, + CreatedAt = DateTime.UtcNow, + CreatedById = employeeId, + IsActive = true + }; + + _context.Organizations.Add(organization); + + // Create the primary Tenant entity + + var tenant = new Tenant + { + Id = tenantId, + Name = tenantEnquire.OrganizationName, + Email = tenantEnquire.Email, + ContactName = $"{tenantEnquire.FirstName} {tenantEnquire.LastName}", + ContactNumber = tenantEnquire.ContactNumber, + OrganizationSize = tenantEnquire.OrganizationSize, + BillingAddress = tenantEnquire.BillingAddress, + IndustryId = tenantEnquire.IndustryId, + Reference = tenantEnquire.Reference, + OnBoardingDate = onBoardingDate, + TenantStatusId = tenantActiveStatus, + OrganizationId = organization.Id, + CreatedById = employeeId, + IsActive = true, + IsSuperTenant = false + }; + + _context.Tenants.Add(tenant); + + // Create the root ApplicationUser for the new tenant + var applicationUser = new ApplicationUser + { + Email = tenantEnquire.Email, + UserName = tenantEnquire.Email, // Best practice to use email as username for simplicity + IsRootUser = true, + EmailConfirmed = true // Auto-confirming email as it's part of a trusted setup process + }; + + // SECURITY WARNING: Hardcoded passwords are a major vulnerability. + // Replace "User@123" with a securely generated random password. + var initialPassword = "User@123"; // TODO: Replace with password generation service. + var result = await _userManager.CreateAsync(applicationUser, initialPassword); + + if (!result.Succeeded) + { + // If user creation fails, roll back the transaction immediately and return the errors. + await transaction.RollbackAsync(); + var errors = result.Errors.Select(e => e.Description).ToList(); + _logger.LogWarning("Failed to create ApplicationUser for tenant {TenantName}. Errors: {Errors}", tenantEnquire.OrganizationName, string.Join(", ", errors)); + return ApiResponse.ErrorResponse("Failed to create user", errors, 400); + } + + // Create the default "Admin" Job Role for the tenant + var adminJobRole = new JobRole + { + Name = AdminRoleName, + Description = "Default administrator role for the tenant.", + TenantId = tenantId + }; + _context.JobRoles.Add(adminJobRole); + + // Create the primary Employee record and link it to the ApplicationUser and JobRole + var employeeUser = new Employee + { + Id = employeeId, + FirstName = tenantEnquire.FirstName, + LastName = tenantEnquire.LastName, + Email = tenantEnquire.Email, + PhoneNumber = tenantEnquire.ContactNumber, + JoiningDate = onBoardingDate, + ApplicationUserId = applicationUser.Id, + JobRole = adminJobRole, // Link to the newly created role + CurrentAddress = tenantEnquire.BillingAddress, + IsActive = true, + IsSystem = false, + IsPrimary = true, + OrganizationId = organization.Id, + HasApplicationAccess = true + }; + _context.Employees.Add(employeeUser); + + var applicationRole = new ApplicationRole + { + Role = "Super User", + Description = "Super User", + IsSystem = true, + TenantId = tenantId + }; + _context.ApplicationRoles.Add(applicationRole); + + var rolePermissionMappigs = new List { + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.ModifyTenant + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.ViewTenant + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.ManageMasters + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.ViewMasters + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.ViewOrganization + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.AddOrganization + }, + new RolePermissionMappings + { + ApplicationRoleId = applicationRole.Id, + FeaturePermissionId = PermissionsMaster.EditOrganization + } + }; + _context.RolePermissionMappings.AddRange(rolePermissionMappigs); + + _context.EmployeeRoleMappings.Add(new EmployeeRoleMapping + { + EmployeeId = employeeUser.Id, + RoleId = applicationRole.Id, + IsEnabled = true, + TenantId = tenantId + }); + + // Create a default project for the new tenant + var project = new Project + { + Name = "Default Project", + ProjectStatusId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"), // Consider using a constant for this GUID + ProjectAddress = tenantEnquire.BillingAddress, + StartDate = onBoardingDate, + EndDate = DateTime.MaxValue, + PromoterId = organization.Id, + PMCId = organization.Id, + ContactPerson = tenant.ContactName, + TenantId = tenantId + }; + _context.Projects.Add(project); + + var projectAllocation = new ProjectAllocation + { + ProjectId = project.Id, + EmployeeId = employeeUser.Id, + AllocationDate = onBoardingDate, + IsActive = true, + JobRoleId = adminJobRole.Id, + TenantId = tenantId + }; + _context.ProjectAllocations.Add(projectAllocation); + + // All entities are now added to the context. Save them all in a single database operation. + await _context.SaveChangesAsync(); + + // 4. --- POST-CREATION ACTIONS --- + // Generate a password reset token so the new user can set their own password. + _logger.LogInfo("User {Email} created. Sending password setup email.", applicationUser.Email); + var token = await _userManager.GeneratePasswordResetTokenAsync(applicationUser); + var resetLink = $"{_configuration["AppSettings:WebFrontendUrl"]}/reset-password?token={WebUtility.UrlEncode(token)}&email={WebUtility.UrlEncode(applicationUser.Email)}"; + await _emailSender.SendResetPasswordEmailOnRegister(applicationUser.Email, employeeUser.FirstName, resetLink); + + // Map the result to a ViewModel for the API response. + var tenantVM = _mapper.Map(tenant); + tenantVM.CreatedBy = _mapper.Map(employeeUser); + + await AddSubscriptionAsync(tenantId, employeeId, paymentDetailId, planId); + + // Commit the transaction as all operations were successful. + await transaction.CommitAsync(); + + _logger.LogInfo("Successfully created tenant {TenantId} for organization {OrganizationName}.", tenant.Id, tenant.Name); + return ApiResponse.SuccessResponse(tenantVM, "Tenant created successfully.", 201); + } + catch (DbUpdateException dbEx) + { + await transaction.RollbackAsync(); + // Log the detailed database exception, including the inner exception if available. + _logger.LogError(dbEx, "A database update exception occurred while creating tenant for email {Email}. Inner Exception: {InnerException}", + tenantEnquire.Email, dbEx.InnerException?.Message ?? string.Empty); + return ApiResponse.ErrorResponse("An internal database error occurred.", ExceptionMapper(dbEx), 500); + } + catch (Exception ex) + { + // Log the general exception. + _logger.LogError(ex, "An unexpected exception occurred while creating tenant for email {Email}.", tenantEnquire.Email); + return ApiResponse.ErrorResponse("An unexpected internal error occurred.", ExceptionMapper(ex), 500); + } + } + + public async Task> AddSubscriptionAsync(Guid tenantId, Guid employeeId, Guid paymentDetailId, Guid planId) + { + + _logger.LogInfo("AddSubscription called for Tenant {TenantId} and Plan {PlanId}", tenantId, planId); + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + using var scope = _serviceScopeFactory.CreateScope(); + + var subscriptionPlan = await _context.SubscriptionPlanDetails.Include(sp => sp.Plan).FirstOrDefaultAsync(sp => sp.Id == planId); + + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.Id == tenantId); + if (tenant == null) + { + _logger.LogWarning("Tenant {TenantId} not found in database", tenantId); + return ApiResponse.ErrorResponse("Tenant not found", "Tenant not found", 404); + } + if (subscriptionPlan == null) + { + _logger.LogWarning("Subscription plan {PlanId} not found in database", planId); + return ApiResponse.ErrorResponse("Subscription plan not found", "Subscription plan not found", 404); + } + var activeUsers = await _context.Employees.CountAsync(e => e.Email != null && e.ApplicationUserId != null && e.TenantId == tenant.Id && e.IsActive); + if (activeUsers > subscriptionPlan.MaxUser) + { + _logger.LogWarning("Add less max user than the active user in the tenant {TenantId}", tenant.Id); + return ApiResponse.ErrorResponse("Invalid Max user count", "Max User count must be higher than active user count", 400); + } + await using var transaction = await _context.Database.BeginTransactionAsync(); + var utcNow = DateTime.UtcNow; + + // Prepare subscription dates based on frequency + var endDate = subscriptionPlan.Frequency switch + { + PLAN_FREQUENCY.MONTHLY => utcNow.AddDays(30), + PLAN_FREQUENCY.QUARTERLY => utcNow.AddDays(90), + PLAN_FREQUENCY.HALF_YEARLY => utcNow.AddDays(120), + PLAN_FREQUENCY.YEARLY => utcNow.AddDays(360), + _ => utcNow // default if unknown + }; + + var tenantSubscription = new TenantSubscriptions + { + TenantId = tenantId, + PlanId = planId, + StatusId = activePlanStatus, + CreatedAt = utcNow, + MaxUsers = subscriptionPlan.MaxUser, + CreatedById = employeeId, + CurrencyId = subscriptionPlan.CurrencyId, + PaymentDetailId = paymentDetailId, + IsTrial = false, + StartDate = utcNow, + EndDate = endDate, + NextBillingDate = endDate, + AutoRenew = false + }; + + _context.TenantSubscriptions.Add(tenantSubscription); + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Tenant subscription added successfully for Tenant {TenantId}, Plan {PlanId}", + tenantId, planId); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database exception while adding subscription plan to tenant {TenantId}", tenantId); + return ApiResponse.ErrorResponse("Internal error occured", ExceptionMapper(dbEx), 500); + } + + try + { + var features = await _featureDetailsHelper.GetFeatureDetails(subscriptionPlan.FeaturesId); + if (features == null) + { + _logger.LogInfo("No features found for subscription plan {PlanId}", planId); + await transaction.CommitAsync(); + return ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200); + } + + // Helper to get permissions for a module asynchronously + async Task> GetPermissionsForModuleAsync(List? featureIds) + { + if (featureIds == null || featureIds.Count == 0) return new List(); + + await using var ctx = await _dbContextFactory.CreateDbContextAsync(); + return await ctx.FeaturePermissions.AsNoTracking() + .Where(fp => featureIds.Contains(fp.FeatureId)) + .Select(fp => fp.Id) + .ToListAsync(); + } + + // Fetch permission tasks for all modules in parallel + var projectPermissionTask = GetPermissionsForModuleAsync(features.Modules?.ProjectManagement?.FeatureId); + var attendancePermissionTask = GetPermissionsForModuleAsync(features.Modules?.Attendance?.FeatureId); + var directoryPermissionTask = GetPermissionsForModuleAsync(features.Modules?.Directory?.FeatureId); + var expensePermissionTask = GetPermissionsForModuleAsync(features.Modules?.Expense?.FeatureId); + var employeePermissionTask = GetPermissionsForModuleAsync(new List { EmployeeFeatureId }); + + await Task.WhenAll(projectPermissionTask, attendancePermissionTask, directoryPermissionTask, expensePermissionTask, employeePermissionTask); + + var newPermissionIds = new List(); + var deletePermissionIds = new List(); + + // Add or remove permissions based on modules enabled status + void ProcessPermissions(bool? enabled, List permissions) + { + if (enabled == true) + newPermissionIds.AddRange(permissions); + else + deletePermissionIds.AddRange(permissions); + } + + ProcessPermissions(features.Modules?.ProjectManagement?.Enabled, projectPermissionTask.Result); + ProcessPermissions(features.Modules?.Attendance?.Enabled, attendancePermissionTask.Result); + ProcessPermissions(features.Modules?.Directory?.Enabled, directoryPermissionTask.Result); + ProcessPermissions(features.Modules?.Expense?.Enabled, expensePermissionTask.Result); + + newPermissionIds = newPermissionIds.Distinct().ToList(); + deletePermissionIds = deletePermissionIds.Distinct().ToList(); + + // Get root employee and role for this tenant + var rootEmployee = await _context.Employees + .Include(e => e.ApplicationUser) + .FirstOrDefaultAsync(e => e.ApplicationUser != null && (e.ApplicationUser.IsRootUser ?? false) && e.OrganizationId == tenant.OrganizationId); + + if (rootEmployee == null) + { + _logger.LogWarning("Root employee not found for tenant {TenantId}", tenantId); + await transaction.CommitAsync(); + return ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200); + } + + var roleId = await _context.EmployeeRoleMappings + .AsNoTracking() + .Where(er => er.EmployeeId == rootEmployee.Id && er.TenantId == tenantId) + .Select(er => er.RoleId) + .FirstOrDefaultAsync(); + + if (roleId == Guid.Empty) + { + _logger.LogWarning("RoleId for root employee {EmployeeId} in tenant {TenantId} not found", rootEmployee.Id, tenantId); + await transaction.CommitAsync(); + return ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200); + } + + var oldRolePermissionMappings = await _context.RolePermissionMappings + .Where(rp => rp.ApplicationRoleId == roleId) + .ToListAsync(); + + var oldPermissionIds = oldRolePermissionMappings.Select(rp => rp.FeaturePermissionId).ToList(); + + // Prevent accidentally deleting essential employee permissions + var permissionIdCount = oldPermissionIds.Count - deletePermissionIds.Count; + if (permissionIdCount <= 4 && deletePermissionIds.Any()) + { + var employeePermissionIds = employeePermissionTask.Result; + deletePermissionIds = deletePermissionIds.Where(p => !employeePermissionIds.Contains(p)).ToList(); + } + + // Prepare mappings to delete and add + var deleteMappings = oldRolePermissionMappings.Where(rp => deletePermissionIds.Contains(rp.FeaturePermissionId)).ToList(); + var addRolePermissionMappings = newPermissionIds + .Where(p => !oldPermissionIds.Contains(p)) + .Select(p => new RolePermissionMappings + { + ApplicationRoleId = roleId, + FeaturePermissionId = p + }) + .ToList(); + + if (addRolePermissionMappings.Any()) + { + _context.RolePermissionMappings.AddRange(addRolePermissionMappings); + _logger.LogInfo("Added {Count} new role permission mappings for role {RoleId}", addRolePermissionMappings.Count, roleId); + } + if (deleteMappings.Any()) + { + _context.RolePermissionMappings.RemoveRange(deleteMappings); + _logger.LogInfo("Removed {Count} role permission mappings for role {RoleId}", deleteMappings.Count, roleId); + } + + var _cache = scope.ServiceProvider.GetRequiredService(); + await _cache.ClearAllEmployeesFromCacheByTenantId(tenant.Id); + + var _masteData = scope.ServiceProvider.GetRequiredService(); + + if (features.Modules?.ProjectManagement?.Enabled ?? false) + { + var workCategoryMaster = _masteData.GetWorkCategoriesData(tenant.Id); + var workStatusMaster = _masteData.GetWorkStatusesData(tenant.Id); + + _context.WorkCategoryMasters.AddRange(workCategoryMaster); + _context.WorkStatusMasters.AddRange(workStatusMaster); + } + if (features.Modules?.Expense?.Enabled ?? false) + { + var expensesTypeMaster = _masteData.GetExpensesTypeesData(tenant.Id); + var paymentModeMatser = _masteData.GetPaymentModesData(tenant.Id); + + _context.ExpensesTypeMaster.AddRange(expensesTypeMaster); + _context.PaymentModeMatser.AddRange(paymentModeMatser); + } + + await _context.SaveChangesAsync(); + + await transaction.CommitAsync(); + + _logger.LogInfo("Permissions updated successfully for tenant {TenantId} subscription", tenantId); + + return ApiResponse.SuccessResponse(tenantSubscription, "Tenant Subscription Successfully", 200); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Exception occurred while updating permissions for tenant {TenantId}", tenantId); + return ApiResponse.ErrorResponse("Internal error occured", ExceptionMapper(ex), 500); + } + } + + #region =================================================================== Helper Functions =================================================================== + private static object ExceptionMapper(Exception ex) + { + return new + { + Message = ex.Message, + StackTrace = ex.StackTrace, + Source = ex.Source, + InnerException = new + { + Message = ex.InnerException?.Message, + StackTrace = ex.InnerException?.StackTrace, + Source = ex.InnerException?.Source, + } + }; + } + private bool IsBase64String(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + string base64Data = input; + const string dataUriMarker = "base64,"; + int markerIndex = input.IndexOf(dataUriMarker, StringComparison.Ordinal); + + // If the marker is found, extract the actual Base64 data + if (markerIndex >= 0) + { + base64Data = input.Substring(markerIndex + dataUriMarker.Length); + } + + // Now, validate the extracted payload + base64Data = base64Data.Trim(); + + // Check for valid length (must be a multiple of 4) and non-empty + if (base64Data.Length == 0 || base64Data.Length % 4 != 0) + { + return false; + } + + // The most reliable test is to simply try to convert it. + // The .NET converter is strict and will throw a FormatException + // for invalid characters or incorrect padding. + try + { + Convert.FromBase64String(base64Data); + return true; + } + catch (FormatException) + { + // The string is not a valid Base64 payload. + return false; + } + } + + /// + /// Handles the creation and persistence of SubscriptionPlanDetails for a particular frequency. + /// + private async Task> CreateSubscriptionPlanDetails(SubscriptionPlanDetailsDto? model, SubscriptionPlan plan, Employee loggedInEmployee, PLAN_FREQUENCY frequency) + { + if (model == null) + { + _logger.LogInfo("No plan detail provided for {Frequency} - skipping.", frequency); + return ApiResponse.ErrorResponse("Invalid", "No data provided for this frequency", 400); + } + + await using var _dbContext = await _dbContextFactory.CreateDbContextAsync(); + + // Fetch currency master record + var currencyMaster = await _dbContext.CurrencyMaster.AsNoTracking().FirstOrDefaultAsync(c => c.Id == model.CurrencyId); + if (currencyMaster == null) + { + _logger.LogWarning("Currency with Id {CurrencyId} not found for plan {PlanId}/{Frequency}.", model.CurrencyId, plan.Id, frequency); + return ApiResponse.ErrorResponse("Currency not found", "Specified currency not found", 404); + } + + // Map to entity and create related feature details + var planDetails = _mapper.Map(model); + var features = _mapper.Map(model.Features); + + try + { + await _featureDetailsHelper.AddFeatureDetails(features); + _logger.LogInfo("FeatureDetails for plan {PlanId}/{Frequency} saved in MongoDB.", plan.Id, frequency); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occurred while saving features in MongoDB for {PlanId}/{Frequency}.", plan.Id, frequency); + return ApiResponse.ErrorResponse("Internal error occurred", ExceptionMapper(ex), 500); + } + + planDetails.PlanId = plan.Id; + planDetails.Frequency = frequency; + planDetails.FeaturesId = features.Id; + planDetails.CreatedById = loggedInEmployee.Id; + planDetails.CreateAt = DateTime.UtcNow; + + _dbContext.SubscriptionPlanDetails.Add(planDetails); + + // Prepare view model + var VM = _mapper.Map(planDetails); + VM.PlanName = plan.PlanName; + VM.Description = plan.Description; + VM.Features = features; + VM.Currency = currencyMaster; + + try + { + await _dbContext.SaveChangesAsync(); + _logger.LogInfo("Subscription plan details for {PlanId}/{Frequency} saved to SQL.", plan.Id, frequency); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database exception occurred while saving plan details for {PlanId}/{Frequency}.", plan.Id, frequency); + return ApiResponse.ErrorResponse("Internal error occurred", ExceptionMapper(dbEx), 500); + } + + return ApiResponse.SuccessResponse(VM, "Success", 200); + } + + #endregion + } +}