using Fitness_core_BE.Domain; using Fitness_core_BE.DTOs.Auth; using Fitness_core_BE.Exceptions; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using Fitness_core_BE.Models.IOs; using System.Text.Json; namespace Fitness_core_BE.Services { public class ClientService : IClientService { private readonly IClientRepository _clientRepository; private readonly ICustomerRepository _customerRepository; private readonly IUnitOfWork _uow; private readonly IAuthService _authService; private readonly ICustomerService _customerService; private readonly ICustomerSubscriptionService _customerSubscriptionService; private readonly IMealService _mealService; private readonly IWorkoutService _workoutService; private readonly UserRoleHelper _userRoleHelper; private readonly IThirdPartyService _thirdPartyService; private readonly IConfiguration _config; private readonly ITermsRepository _termsRepository; private readonly IPaymentService _paymentService; private readonly IAccessDeviceService _accessDeviceService; private readonly ICustomerInvoiceRepository _customerInvoiceRepository; public ClientService(IClientRepository _clientRepository, ICustomerRepository customerRepository, IUnitOfWork uow, IAuthService authService, ICustomerService customerService, ICustomerSubscriptionService customerSubscriptionService, IMealService mealService, IWorkoutService workoutService, UserRoleHelper userRoleHelper, IThirdPartyService thirdPartyService, IConfiguration config, ITermsRepository termsRepository, IPaymentService paymentService, IAccessDeviceService accessDeviceService, ICustomerInvoiceRepository customerInvoiceRepository) { this._clientRepository = _clientRepository; _customerRepository = customerRepository; _uow = uow; _authService = authService; _customerService = customerService; _customerSubscriptionService = customerSubscriptionService; _mealService = mealService; _workoutService = workoutService; _userRoleHelper = userRoleHelper; _thirdPartyService = thirdPartyService; _config = config; _termsRepository = termsRepository; _paymentService = paymentService; _accessDeviceService = accessDeviceService; _customerInvoiceRepository = customerInvoiceRepository; } //V1 implementation public async Task GetAllCustomers(object payload, HttpContext httpContext) { return await _clientRepository.GetAllCustomers(); } public async Task GetCustomerDetailsById(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid customerId = Guid.Parse(data["customerId"].GetString()!); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var customer = await _clientRepository.GetCustomerById(customerId); if (customer == null) throw new KeyNotFoundException($"Customer with ID {customerId} not found"); customer.CustomerSubscriptions = null; // SubscriptionMembers-based: also finds subscriptions where this customer // is a group secondary (customer.CustomerSubscriptions only sees primaries). var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId); // Legacy `plans` shape retained for backward compatibility. var plans = subscriptions .Select(s => new { customerPlanId = s.CustomerPlanId, planName = s.PlanName, planPrice = s.PlanPrice, durationInDays = 0, planScope = s.PlanScope, isActive = s.IsActive }) .ToList(); return new { customer, plans, subscriptions }; } // Aggregated member profile for the "More Info" view: basic details + targets + // weight progress (already loaded on the customer) plus the plans they follow. // Medical details are deliberately excluded — they require a password (GetCustomerMedical). public async Task GetCustomerFullDetails(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid customerId = Guid.Parse(data["customerId"].GetString()!); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var customer = await _clientRepository.GetCustomerById(customerId) ?? throw new KeyNotFoundException($"Customer with ID {customerId} not found"); customer.CustomerSubscriptions = null; var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId); var workoutPlans = await _clientRepository.GetFollowedWorkoutPlansAsync(customerId); var mealPlans = await _clientRepository.GetFollowedMealPlansAsync(customerId); return new { customer, subscriptions, workoutPlans, mealPlans }; } // Password-gated medical details. The owner re-enters their account password, which is // verified against the auth service; only on success are BloodGroup/Disabilities returned. public async Task GetCustomerMedical(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid customerId = Guid.Parse(data["customerId"].GetString()!); string password = DataExtractor.GetRequiredString(data, "password"); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var verify = await _authService.VerifyPassword(new { password }, httpContext); if (verify.StatusCode != 200 || !ExtractValid(verify.Data)) throw new UnauthorizedException("Password is incorrect."); var health = await _clientRepository.GetCustomerHealthAsync(customerId); return new { bloodGroup = health?.BloodGroup, disabilities = health?.Disabilities }; } // The auth VerifyPassword returns { valid } (possibly wrapped in a data envelope). private static bool ExtractValid(object? data) { if (data is not JsonElement el || el.ValueKind != JsonValueKind.Object) return false; if (el.TryGetProperty("valid", out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False)) return v.GetBoolean(); if (el.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object && d.TryGetProperty("valid", out var v2) && (v2.ValueKind == JsonValueKind.True || v2.ValueKind == JsonValueKind.False)) return v2.GetBoolean(); return false; } //V1 implementation public async Task DeleteCustomerByClient (object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid customerId = Guid.Parse(data["customerId"].GetString()!); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); // Revoke physical access at this gym first, while the membership is still active // in the DB (SetCustomerAccessAsync requires an active membership at clientId to // resolve which AccessProfile to touch). No profile exists if the member never // completed device enrollment — nothing to revoke in that case. try { await _accessDeviceService.SetCustomerAccessAsync(clientId, customerId, enabled: false, reason: "Removed from gym"); } catch (NotFoundException) { } // Client-initiated delete only deactivates this customer's subscriptions/plan // assignments at THIS gym — the shared central Customer record (and any // membership at another gym) must never be touched by a single client's delete. var result = await _customerRepository.DeactivateCustomerForClient(customerId, clientId); if (!result) { throw new KeyNotFoundException("Customer Not Found"); } await _uow.CommitAsync(); return new { success = true, message = "Customer deleted successfully" }; } catch { await _uow.RollbackAsync(); throw new KeyNotFoundException("Error"); } } public async Task ClientLogOut (object payload, HttpContext httpContext) { try { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); var authPayload = new { userId = userId }; AuthResponse authResponse = await _authService.LogOut(authPayload, httpContext); if (!authResponse.Success) { throw new InvalidOperationException(authResponse.Message); } return new { success = true, message = "Logged out successfully", auth = authResponse }; } catch { throw; } } public async Task GetClientList(object payload, HttpContext httpContext) { try { var clients = await _clientRepository.GetAllClientsAsync(); return clients; } catch { throw; } } public async Task GetClientById(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var client = await _clientRepository.GetClientByIdAsync(clientId); if (client == null) throw new KeyNotFoundException("Client not found"); return client; } catch { throw; } } public async Task UpdateGymProfile( UpdateGymProfileRequest request, HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); await _uow.BeginAsync(); try { var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId) ?? throw new KeyNotFoundException("Gym business profile not found"); if (request.BusinessName != null) business.BusinessName = request.BusinessName.Trim(); if (request.Address != null) business.Address = request.Address.Trim(); if (request.City != null) business.City = request.City.Trim(); if (request.Description != null) business.Description = request.Description.Trim(); // AC + equipment live as JSON in Facilities; preserve whichever isn't sent. if (request.HasAc.HasValue || request.Equipment != null) { var current = GymFacilities.Parse(business.Facilities); var hasAc = request.HasAc ?? current.HasAc; var equipment = request.Equipment == null ? current.Equipment : request.Equipment .Select(item => new EquipmentCount(item.Name, item.Count)) .ToList(); business.Facilities = GymFacilities.Serialize(hasAc, equipment); } await _uow.CommitAsync(); var savedFacilities = GymFacilities.Parse(business.Facilities); return new { success = true, message = "Gym profile updated", facilities = new { hasAc = savedFacilities.HasAc, equipment = savedFacilities.Equipment } }; } catch { await _uow.RollbackAsync(); throw; } } // Gym-owner view of their own gym's active Terms & Conditions (for editing). public async Task GetMyGymTerms(object payload, HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var terms = await _termsRepository.GetActiveByClientIdAsync(clientId); if (terms == null) return new { hasTerms = false }; return new { hasTerms = true, terms.Version, terms.Content, terms.CreatedAt }; } // Publishes a new Terms version for the gym owner's own gym; deactivates the old one. public async Task PublishGymTerms(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); string content = DataExtractor.GetRequiredString(data, "content"); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var currentTerms = await _termsRepository.GetActiveForUpdateAsync(clientId); foreach (var current in currentTerms) current.IsActive = false; var latestVersion = await _termsRepository.GetLatestVersionAsync(clientId); var terms = new ClientTerms { ClientTermsId = Guid.NewGuid(), FKClientId = clientId, Version = latestVersion + 1, Content = content, IsActive = true, CreatedAt = DateTime.UtcNow }; await _termsRepository.AddTermsAsync(terms); await _uow.SaveChangesAsync(); return new { success = true, terms.Version, terms.CreatedAt }; } public async Task UploadGymLogo(IFormFile logo, HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); if (logo == null || logo.Length == 0) throw new BadRequestException("No logo file provided."); string extension = Path.GetExtension(logo.FileName).ToLowerInvariant(); string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" }; if (!allowed.Contains(extension)) throw new BadRequestException("Logo must be an image (jpg, png, webp)."); if (logo.Length > 5 * 1024 * 1024) throw new BadRequestException("Logo too large (max 5MB)."); string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "gym-logos", clientId.ToString())); if (!Directory.Exists(uploadsRoot)) Directory.CreateDirectory(uploadsRoot); string filename = $"logo_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}"; string filePath = Path.Combine(uploadsRoot, filename); using (var stream = new FileStream(filePath, FileMode.Create)) { await logo.CopyToAsync(stream); } await _uow.BeginAsync(); try { var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId) ?? throw new KeyNotFoundException("Gym business profile not found"); business.BusinessLogoId = $"/Uploads/gym-logos/{clientId}/{Uri.EscapeDataString(filename)}"; await _uow.CommitAsync(); return new { businessLogoId = business.BusinessLogoId }; } catch { await _uow.RollbackAsync(); throw; } } public async Task GetGymLogo(HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId); var logo = await BusinessLogoHelper.ReadAsDataUrlAsync(business?.BusinessLogoId); return new { logo }; } // Sends an OTP to the owner's new email/phone before it replaces the current one. public async Task SendContactOtp(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); string identifier = DataExtractor.GetRequiredString(data, "identifier"); var authResponse = await _authService.SendOtp(new { identifier, newUser = true }, httpContext); if (!authResponse.Success) throw new BadRequestException($"Failed to send OTP: {authResponse.Message}"); return AuthOtpResponseParser.ToSendOtpResult(authResponse); } // Verifies the OTP, writes the new value to the auth DB (UpdateUser), then mirrors it // onto the local Client record so the dashboard shows the change immediately. public async Task VerifyContactOtp(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); string type = DataExtractor.GetRequiredString(data, "type").ToLowerInvariant(); string identifier = DataExtractor.GetRequiredString(data, "identifier"); string referenceNumber = DataExtractor.GetRequiredString(data, "referenceNumber"); string otpCode = DataExtractor.GetRequiredString(data, "otpCode"); if (type != "email" && type != "phone") throw new BadRequestException("Contact type must be 'email' or 'phone'."); var verify = await _authService.VerifyOTP(new { referenceNumber, otpCode, newUser = true }, httpContext); if (!verify.Success) throw new BadRequestException($"OTP verification failed: {verify.Message}"); object updatePayload = type == "email" ? new { email = identifier } : new { mobileNumber = identifier }; var update = await _authService.UpdateUser(updatePayload, httpContext); if (!update.Success) throw new BadRequestException($"Failed to update account: {update.Message}"); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); await _uow.BeginAsync(); try { var client = await _clientRepository.GetClientEntityByIdAsync(clientId) ?? throw new KeyNotFoundException("Client not found"); if (type == "email") client.ClientEmail = identifier; else client.ClientPhone = identifier; await _uow.CommitAsync(); } catch { await _uow.RollbackAsync(); throw; } return new { success = true, message = type == "email" ? "Email updated" : "Phone number updated" }; } public async Task CreateCustomerWithPlans(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); var clientId = await _userRoleHelper.GetClientId( DataExtractor.GetUserIdFromContext(httpContext)); var mobileNumber = DataExtractor.GetRequiredString(data, "mobileNumber"); var email = DataExtractor.GetOptionalString(data, "email"); var confirmLink = DataExtractor.GetOptionalBool(data, "confirmLink") ?? false; // Reject outright if this gym already has this phone/email as an active member -- // re-adding them here would otherwise silently stack a duplicate subscription. var ownGymDuplicate = await _customerRepository.FindActiveLinkByIdentifierAsync(mobileNumber) ?? (!string.IsNullOrWhiteSpace(email) ? await _customerRepository.FindActiveLinkByIdentifierAsync(email) : null); if (ownGymDuplicate != null) throw new ArgumentException("This phone number or email is already used by another customer at your gym."); var existingCustomer = await _customerRepository.GetCustomerByIdentifier(mobileNumber) ?? (!string.IsNullOrWhiteSpace(email) ? await _customerRepository.GetCustomerByIdentifier(email) : null); // Not at this gym, but already a platform customer elsewhere -- don't auto-link; // let staff confirm this is really the same person first. Nothing written yet. if (existingCustomer != null && !confirmLink) { return new { success = true, requiresConfirmation = true, message = "This phone number or email already belongs to an existing HexaFitness customer. Add them as your member?", existingCustomer = new { existingCustomer.CusId, existingCustomer.CusName, existingCustomer.PrimaryPH, existingCustomer.Email } }; } await _uow.BeginAsync(); try { if (existingCustomer != null) { existingCustomer.IsActive = true; if (existingCustomer.User != null) { existingCustomer.User.IsActive = true; existingCustomer.User.UserName = DataExtractor.GetRequiredString(data, "userName"); existingCustomer.User.PhoneNumber = mobileNumber; if (!string.IsNullOrWhiteSpace(email)) existingCustomer.User.Email = email; } existingCustomer.CusName = DataExtractor.GetRequiredString(data, "fullname"); existingCustomer.Gender = DataExtractor.GetRequiredString(data, "gender"); existingCustomer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"); existingCustomer.Email = email; existingCustomer.PrimaryPH = mobileNumber; existingCustomer.NIC = DataExtractor.GetOptionalString(data, "nic"); existingCustomer.Address = DataExtractor.GetOptionalString(data, "address"); await _uow.SaveChangesAsync(); // The central Customer may predate this gym entirely (same person already // a member elsewhere), in which case this tenant DB has no link row yet. await _customerRepository.EnsureTenantCustomerLink(existingCustomer); await _uow.SaveChangesAsync(); await _accessDeviceService.EnsureAccessProfileAsync( clientId, existingCustomer.User?.UserId ?? Guid.Empty, "Customer", existingCustomer.CusName ?? existingCustomer.User?.UserName ?? string.Empty); Guid? existingSubscriptionId = null; Guid? existingInvoiceId = null; decimal? existingInvoiceAmount = null; if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || data.ContainsKey("customerPlanId")) { var assignResult = await _customerService.AssignPlansToCustomer(existingCustomer.CusId, payload, httpContext); var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId"); existingSubscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?; var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId"); existingInvoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?; var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount"); existingInvoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?; } await _uow.CommitAsync(); return new { success = true, message = "Customer already existed in core DB and was reactivated.", customer = existingCustomer, subscriptionId = existingSubscriptionId, invoiceId = existingInvoiceId, invoiceAmount = existingInvoiceAmount, paymentLinkSent = false, paymentLinkError = (string?)null, auth = new { skipped = true } }; } var user = new User { UserName = DataExtractor.GetRequiredString(data, "userName"), PhoneNumber = mobileNumber, Email = email, IsActive = true, CreateDate = DateTime.UtcNow }; var customer = new Customer { CusName = DataExtractor.GetRequiredString(data, "fullname"), Gender = DataExtractor.GetRequiredString(data, "gender"), BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"), Email = email, PrimaryPH = mobileNumber, NIC = DataExtractor.GetOptionalString(data, "nic"), Address = DataExtractor.GetOptionalString(data, "address"), IsPrimaryPHVerfied = false, User = user }; var result = await _customerRepository.CreateCustomer(customer); await _uow.SaveChangesAsync(); // Must precede AssignPlansToCustomer: the tenant-side subscription FKs point // at this link row, so it has to be persisted before any of them are inserted. await _customerRepository.EnsureTenantCustomerLink(result); await _uow.SaveChangesAsync(); await _accessDeviceService.EnsureAccessProfileAsync( clientId, user.UserId, "Customer", customer.CusName ?? user.UserName ?? string.Empty); var tempPassword = Guid.NewGuid().ToString("N").Substring(0, 8); AuthResponse authResponse; try { Guid customerRoleId = Guid.Parse("08de6a11-9e9f-4401-8a10-685cc57b41ed"); Guid customerUserTypeId = Guid.Parse("00000000-0000-0000-0000-000000000001"); var authPayload = DeserializePayload.Deserialize(payload); authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId); authPayload["roleId"] = JsonSerializer.SerializeToElement(customerRoleId); authPayload["userTypeId"] = JsonSerializer.SerializeToElement(customerUserTypeId); authPayload["password"] = JsonSerializer.SerializeToElement(tempPassword); authPayload["mobileNumber"] = JsonSerializer.SerializeToElement(user.PhoneNumber); authResponse = await _authService.RegisterUser(authPayload, httpContext); } catch (Exception ex) { throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); } if (authResponse.StatusCode != 200 || !authResponse.Success) { throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); } Guid? subscriptionId = null; Guid? invoiceId = null; decimal? invoiceAmount = null; if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || data.ContainsKey("customerPlanId")) { var assignResult = await _customerService.AssignPlansToCustomer(result.CusId, payload, httpContext); // The FE opens the "add group members" step right after creating a // member on a Group plan, so surface the new subscription's id. var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId"); subscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?; var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId"); invoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?; var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount"); invoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?; } await _uow.CommitAsync(); // Send the member their app login (Gmail email + SMS with the mobile-app // link, username and password). Non-critical: delivery failures must not // roll back the created customer. await SendCustomerWelcomeAsync(user.UserName, user.Email, user.PhoneNumber, tempPassword); bool paymentLinkSent = false; string? paymentLinkError = null; if (invoiceId.HasValue && invoiceAmount is > 0) { try { await _paymentService.SendPaymentLink(invoiceId.Value, httpContext); paymentLinkSent = true; } catch (Exception ex) { // Account/subscription creation is complete. Delivery can be retried // from the member invoice without rolling the member back. paymentLinkError = ex.Message; Console.WriteLine($"Automatic customer payment link failed (non-critical): {ex.Message}"); } } return new { success = true, message = "Customer created and plans assigned successfully", customer = result, subscriptionId, invoiceId, paymentLinkSent, paymentLinkError, auth = authResponse }; } catch { await _uow.RollbackAsync(); throw; } } // Delivers a newly created member's app login over both channels. Each channel is // isolated so one failing (e.g. no email configured) still lets the other through, // and any failure is swallowed — the member already exists at this point. private async Task SendCustomerWelcomeAsync(string? userName, string? email, string? phoneNumber, string password) { var appLink = _config["App:MobileAppLink"] ?? ""; if (!string.IsNullOrWhiteSpace(email)) { try { string html = $"

Hi {userName},

" + "

Your gym membership account has been created. Use the credentials below to log in to the Hexa Fitness mobile app:

" + $"

Username: {userName}
Password: {password}

" + (string.IsNullOrWhiteSpace(appLink) ? "" : $"

Download the app: {appLink}

") + "

We recommend changing your password after your first login.

" + "

— Hexa Fitness

"; await _thirdPartyService.SendEmailAsync(email!, "Your Hexa Fitness app login", html); } catch (Exception ex) { Console.WriteLine($"Customer welcome email failed (non-critical): {ex.Message}"); } } if (!string.IsNullOrWhiteSpace(phoneNumber)) { try { string sms = $"Welcome to Hexa Fitness!" + (string.IsNullOrWhiteSpace(appLink) ? "" : $" Download: {appLink}"); await _thirdPartyService.SendSmsAsync(phoneNumber!, null, sms); } catch (Exception ex) { Console.WriteLine($"Customer welcome SMS failed (non-critical): {ex.Message}"); } } } public async Task UpdateCustomerByClient(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId"); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var customer = await _customerRepository.GetCustomerById(customerId); if (customer == null) throw new NotFoundException("Customer not found"); customer.CusName = DataExtractor.GetRequiredString(data, "fullname"); customer.Gender = DataExtractor.GetRequiredString(data, "gender"); customer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"); customer.Email = DataExtractor.GetOptionalString(data, "email"); customer.PrimaryPH = DataExtractor.GetOptionalString(data, "phoneNumber"); customer.NIC = DataExtractor.GetOptionalString(data, "nic"); customer.Address = DataExtractor.GetOptionalString(data, "address"); var userName = DataExtractor.GetRequiredString(data, "userName"); if (customer.User != null) { customer.User.UserName = userName; customer.User.Email = customer.Email; customer.User.PhoneNumber = customer.PrimaryPH; } var result = await _customerRepository.UpdateCustomer(customerId, customer); if (result == null) throw new KeyNotFoundException("Customer not found"); // This gym's own TenantCustomerLink caches name/phone/email for list and payment // views -- without this it keeps showing whatever was cached at link time, drifting // from the central edit above. EnsureTenantCustomerLink is idempotent, so this is // just a refresh when the link already exists. await _customerRepository.EnsureTenantCustomerLink(result); // A client-side profile edit must not silently drift from what the customer logs // in with — push the same name/contact fields to their Auth login credential. // Failure here throws (caught below), rolling back the local edit too, so the // two never end up out of sync in either direction. var authResponse = await _authService.AdminUpdateUser(new { userId = customer.FKUserId, userName, email = customer.Email, mobileNumber = customer.PrimaryPH }, httpContext); if (authResponse.StatusCode != 200 || !authResponse.Success) throw new InvalidOperationException($"Auth profile sync failed: {authResponse.Message}"); if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || data.ContainsKey("customerPlanId")) { await _customerService.AssignPlansToCustomer(customerId, payload, httpContext); } await _uow.CommitAsync(); return result; } catch { await _uow.RollbackAsync(); throw; } } public async Task AssignPlanToExistingCustomer(Guid customerId, object payload, HttpContext httpContext) { await EnsureCustomerBelongsToCallerClient(customerId, httpContext); var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("customerPlanId") || data["customerPlanId"].ValueKind == JsonValueKind.Null) throw new ArgumentException("customerPlanId is required."); return await _customerService.AssignPlansToCustomer(customerId, payload, httpContext); } // Switches a customer already on an Individual-scope plan to a different Individual-scope // plan: deactivates their current subscription (and its members/subitems/installments) // and starts a fresh one via CustomerSubscriptionFactory, mirroring how AssignPlansToCustomer // builds a first subscription — but self-contained here rather than reusing that method, // since it assumes the customer has no existing active subscription. // // Settles the old plan's billing rather than abandoning it: // - old invoice still Pending/Partial -> mark it RolledOver and carry the unpaid // balance onto a full-price invoice for the new plan (same mechanism the renewal // sweep already uses for exactly this). // - old invoice Paid, new plan costs more -> a single settlement invoice for just the // difference (not the new plan's full price plus the difference). // - old invoice Paid, new plan costs less -> the same settlement invoice, but negative: // a "Credit" invoice the customer's next bill (any kind) will automatically deduct. public async Task ChangeCustomerMembershipPlan(Guid customerId, Guid newCustomerPlanId, HttpContext httpContext) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); await EnsureCustomerBelongsToCallerClient(customerId, httpContext); await _uow.BeginAsync(); try { var newPlan = await _clientRepository.GetCustomerPlanEntityForClientAsync(newCustomerPlanId, clientId) ?? throw new NotFoundException("Membership plan not found."); if (newPlan.PlanScope != "Individual") throw new BadRequestException("Change Membership only supports Individual-scope plans."); var oldMembership = await _clientRepository.GetActiveMembershipForCustomerAsync(customerId); if (oldMembership?.Subscription.FKCustomerPlanId == newCustomerPlanId) throw new BadRequestException("This member is already on that plan."); // Read the old subscription's billing state before ReplaceCustomerSubscriptionAsync // deactivates it -- GetInvoiceBySubscriptionIdAsync still resolves correctly on an // active subscription regardless of what happens to it a moment later. var oldInvoice = oldMembership != null ? await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(oldMembership.FKSubscriptionId) : null; var subscription = new Models.CustomerSubscription { FKCustomerPlanId = newCustomerPlanId, FKCustomerId = customerId, OnboardDate = DateTime.UtcNow, IsActive = true }; var initialSubItem = new Models.CustomerSubItem { SubItemName = "Membership change", IsActive = true }; var aggregate = CustomerSubscriptionFactory.Create(subscription, newPlan, initialSubItem); await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate); await _uow.SaveChangesAsync(); // Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs) // outranks a bare `CustomerInvoice` here, so the model type needs its full name. Fitness_core_BE.Models.CustomerInvoice invoice; if (oldInvoice == null || oldInvoice.Status == "Pending" || oldInvoice.Status == "Partial") { decimal carriedOverBalance = 0m; if (oldInvoice != null) { var approvedPaid = await _customerInvoiceRepository.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId); carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid); await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(oldInvoice.CustomerInvoiceId, "RolledOver"); } invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription( subscription.SubscriptionId, chargePayment: true, carriedOverBalance); } else { // oldInvoice is Paid (any other status would mean the old subscription was // never properly billed, which "shouldn't happen" for an active membership -- // treated the same as Paid here rather than adding a third branch for it). var diff = newPlan.PlanPrice - oldInvoice.Amount; invoice = await _customerInvoiceRepository.CreatePlanChangeInvoiceAsync( subscription.SubscriptionId, "Membership change", diff); } await _uow.CommitAsync(); return new { success = true, message = "Membership plan changed", subscriptionId = subscription.SubscriptionId, invoiceId = invoice.CustomerInvoiceId, invoiceAmount = invoice.Amount, invoiceStatus = invoice.Status }; } catch { await _uow.RollbackAsync(); throw; } } // Drops the member back to "no plan": if they're the primary member of a subscription, // the whole subscription (and every other member on it) is deactivated; otherwise only // their own membership row is. No new subscription/invoice is created. public async Task UnassignCustomerMembership(Guid customerId, HttpContext httpContext) { await EnsureCustomerBelongsToCallerClient(customerId, httpContext); await _uow.BeginAsync(); try { var membership = await _clientRepository.GetMembershipForCustomerAsync(customerId) ?? throw new BadRequestException("This member doesn't have a plan to unassign."); // Toggle: the same button unassigns an assigned member and re-assigns them // to the plan they were already on. The subscription itself is left active // either way, so nothing has to be rebuilt to put them back. var assigned = !membership.IsPlanAssigned; // Unassigning the primary takes the whole group off the shared plan (it is one // entitlement); a secondary only unassigns themselves. await _clientRepository.SetMembershipPlanAssignedAsync( membership.FKSubscriptionId, assigned, wholeSubscription: membership.IsPrimary, membership.SubscriptionMemberId); await _uow.SaveChangesAsync(); await _uow.CommitAsync(); return new { success = true, isPlanAssigned = assigned, message = assigned ? "Membership assigned" : "Membership unassigned" }; } catch { await _uow.RollbackAsync(); throw; } } public async Task GetCustomersByClientId(object payload, HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var subscriptions = await _clientRepository.GetCustomersByClientIdAsync(clientId); return subscriptions; } private async Task EnsureCustomerBelongsToCallerClient(Guid customerId, HttpContext httpContext) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); if (!await _clientRepository.CustomerBelongsToClientAsync(customerId, clientId)) throw new ForbiddenException("This customer belongs to another gym."); } public async Task GetCustomerPlansByClientId(object payload, HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId); return plans; } public async Task RegisterClient(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); // Helper to extract double values safely double? GetDouble(string key) { if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.Number) return data[key].GetDouble(); if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.String && double.TryParse(data[key].GetString(), out var result)) return result; return null; } // Create User first var user = new User { UserName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : "unknown", Email = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null, PhoneNumber = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null, IsActive = true, CreateDate = DateTime.UtcNow }; var client = new Client { ClientName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : null, ClientPhone = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null, ClientEmail = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null, User = user }; await _clientRepository.RegisterClientAsync(client); var clientBussiness = new ClientBusiness { FKClientId = client.ClientId, BusinessType = data.ContainsKey("BusinessType") ? (Fitness_core_BE.Models.Enums.BusinessType)Enum.Parse(typeof(Fitness_core_BE.Models.Enums.BusinessType), data["BusinessType"].GetString() ?? "Individual") : Fitness_core_BE.Models.Enums.BusinessType.institute, BusinessName = data.ContainsKey("BusinessName") ? data["BusinessName"].GetString() : null, BusinessLogoId = data.ContainsKey("BusinessLogoId") ? data["BusinessLogoId"].GetString() : null, Description = data.ContainsKey("Description") ? data["Description"].GetString() : null, IsPysicalLocation = data.ContainsKey("IsPysicalLocation") ? data["IsPysicalLocation"].GetBoolean() : true, Address = data.ContainsKey("Address") ? data["Address"].GetString() : null, City = data.ContainsKey("City") ? data["City"].GetString() : null, Facilities = data.ContainsKey("facilities") ? data["facilities"].GetString() : null, CoachingMode = data.ContainsKey("CoachingMode") ? data["CoachingMode"].GetString() : null, Expertise = data.ContainsKey("Expertise") ? data["Expertise"].GetString() : null, Gender = data.ContainsKey("Gender") ? data["Gender"].GetString() : null, ExperienceYears = data.ContainsKey("ExperienceYears") ? data["ExperienceYears"].GetString() : null, SessionType = data.ContainsKey("SessionType") ? data["SessionType"].GetString() : null, StoreType = data.ContainsKey("StoreType") ? data["StoreType"].GetString() : null, ProductCategory = data.ContainsKey("ProductCategory") ? data["ProductCategory"].GetString() : null, DeliveryAvailable = data.ContainsKey("DeliveryAvailable") ? data["DeliveryAvailable"].GetString() : null, Latitude = GetDouble("latitude"), Longitude = GetDouble("longitude"), }; Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000002"); Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6859860b41ed"); Guid userId = user.UserId; await _clientRepository.RegisterBussinessDetailAsync(clientBussiness); await _uow.CommitAsync(); var authPayload = new { email = client.ClientEmail, userTypeId = userTypeId, roleId = roleId, userId}; AuthResponse authResponse; try { authResponse = await _authService.RegisterUser(authPayload, httpContext); } catch (Exception ex) { throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); } if (authResponse.StatusCode != 200 || !authResponse.Success) { throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); } return new { client, auth = authResponse }; } catch { await _uow.RollbackAsync(); throw; } } public async Task GetPackageList(object payload, HttpContext httpContext) { try { var packages = await _clientRepository.GetPackageListAsync(); return packages; } catch { throw; } } public async Task GetAllCustomerPlans(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId); return plans; } catch { throw; } } public async Task GetCustomerPlanById(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid customerPlanId = DataExtractor.GetRequiredGuid(data, "customerPlanId"); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plan = await _clientRepository.GetCustomerPlanByIdForClientAsync(customerPlanId, clientId); if (plan == null) throw new NotFoundException($"Customer plan with ID {customerPlanId} not found"); return plan; } catch { throw; } } public async Task GetMealPlansByClientId(object payload, HttpContext httpContext) { try { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plans = await _clientRepository.GetMealPlansByClientIdAsync(clientId); return plans; } catch { throw; } } public async Task GetMealPlanById(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plan = await _clientRepository.GetMealPlanByIdForClientAsync(mealPlanId, clientId); if (plan == null) throw new NotFoundException($"Meal plan with ID {mealPlanId} not found"); if (plan.MealItems != null) { foreach (var item in plan.MealItems) { var itemAsString = item.MealItems as string; if (!string.IsNullOrEmpty(itemAsString)) { try { item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); } catch { // keep as string if parsing fails } } } } return plan; } catch { throw; } } public async Task GetWorkoutPlansByClientId(object payload, HttpContext httpContext) { try { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plans = await _clientRepository.GetWorkoutPlansByClientIdAsync(clientId); return plans; } catch { throw; } } public async Task GetWorkoutPlanById(object payload, HttpContext httpContext) { try { var data = DeserializePayload.Deserialize(payload); Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var plan = await _clientRepository.GetWorkoutPlanByIdForClientAsync(workoutPlanId, clientId); if (plan == null) throw new Exceptions.NotFoundException($"Workout plan with ID {workoutPlanId} not found"); var transformedWorkoutItems = plan.WorkoutItems?.Select(item => { object? exercisesObj = null; if (!string.IsNullOrEmpty(item.Exercises)) { try { exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); } catch { exercisesObj = item.Exercises; } } return new { item.WorkoutItemId, item.ExerciseDay, item.Description, Exercises = exercisesObj, item.HowToDo, item.DoURL, item.WorkoutPlanId, item.IsActive, item.CreateDate, item.UpdatedAt }; }).ToList(); return new { plan.WorkoutPlanId, plan.Name, plan.Description, plan.IsTemplate, plan.WorkoutCategoryId, plan.WorkoutCategoryName, plan.ClientId, plan.ClientName, plan.IsActive, plan.CreateDate, WorkoutItems = transformedWorkoutItems, AssignedCustomers = plan.AssignedCustomers }; } catch { throw; } } public async Task GetCardDetails(object payload, HttpContext httpContext) { try { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var cards = await _clientRepository.GetCardsByClientIdAsync(clientId); return cards; } catch { throw; } } public async Task GetCustomerCards(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId"); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var cards = await _clientRepository.GetCardsByCustomerForClientAsync(customerId, clientId); return cards.Select(c => new { jobId = c.JobId, cardId = c.CardId, cardNumber = c.CardNumber, cardType = c.CardType, nickName = c.NickName, isPrimary = c.IsPrimary, consentGiven = c.ConsentGiven }); } public async Task GetLockPolicy(HttpContext httpContext) { Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var client = await _clientRepository.GetClientEntityByIdAsync(clientId) ?? throw new KeyNotFoundException("Client not found"); return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue }; } public async Task UpdateLockPolicy(int? lockAfterDaysOverdue, HttpContext httpContext) { if (lockAfterDaysOverdue is < 0) throw new ArgumentException("lockAfterDaysOverdue cannot be negative."); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var client = await _clientRepository.GetClientEntityByIdAsync(clientId) ?? throw new KeyNotFoundException("Client not found"); client.LockAfterDaysOverdue = lockAfterDaysOverdue; await _uow.SaveChangesAsync(); return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue }; } } }