first commit
This commit is contained in:
@@ -0,0 +1,803 @@
|
||||
using Fitness_core_BE.DTOs;
|
||||
using Fitness_core_BE.DTOs.AccessDevice;
|
||||
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 System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns the cloud-side desired state for physical access. User identity is scoped by
|
||||
/// client in AccessProfile; terminal-specific state lives in AccessDeviceBinding.
|
||||
/// Fingerprint template bytes never leave the terminal.
|
||||
/// </summary>
|
||||
public class AccessDeviceService : IAccessDeviceService
|
||||
{
|
||||
private readonly IUnitOfWork _UoW;
|
||||
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
||||
private readonly IAttendanceRepository _attendanceRepo;
|
||||
private readonly IDeviceCommandRepo _deviceCommandRepo;
|
||||
private readonly IPairingCodeRepo _pairingCodeRepo;
|
||||
|
||||
public AccessDeviceService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAccessDeviceRepo accessDeviceRepo,
|
||||
IAttendanceRepository attendanceRepo,
|
||||
IDeviceCommandRepo deviceCommandRepo,
|
||||
IPairingCodeRepo pairingCodeRepo)
|
||||
{
|
||||
_UoW = unitOfWork;
|
||||
_accessDeviceRepo = accessDeviceRepo;
|
||||
_attendanceRepo = attendanceRepo;
|
||||
_deviceCommandRepo = deviceCommandRepo;
|
||||
_pairingCodeRepo = pairingCodeRepo;
|
||||
}
|
||||
|
||||
public async Task<object> AddDevice(object payload)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
try
|
||||
{
|
||||
var device = new AccessDevice
|
||||
{
|
||||
DeviceId = Guid.NewGuid(),
|
||||
DeviceName = DataExtractor.GetRequiredString(data, "DeviceName"),
|
||||
FKClientId = DataExtractor.GetRequiredGuid(data, "FKClientId")
|
||||
};
|
||||
|
||||
await _accessDeviceRepo.AddDeviceAsync(device);
|
||||
await _UoW.CommitAsync();
|
||||
return new { Message = "Device added", device.DeviceId };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> RemoveDevice(object payload)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
try
|
||||
{
|
||||
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
|
||||
var result = await _accessDeviceRepo.RemoveDeviceAsync(clientId);
|
||||
await _UoW.CommitAsync();
|
||||
return new { Message = $"Device {result.DeviceId} disabled", result.DeviceId };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GeneratePairingCode(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
|
||||
var pairingCode = new PairingCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = GenerateShortCode(),
|
||||
FKClientId = clientId,
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _pairingCodeRepo.AddAsync(pairingCode);
|
||||
await _UoW.CommitAsync();
|
||||
return new { pairingCode.Code, pairingCode.ExpiresAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility entry point for old admin callers. New flows create an
|
||||
/// AccessProfile automatically when the customer/staff record is created.
|
||||
/// </summary>
|
||||
public async Task<object> AddUser(CustomerAccess requested)
|
||||
{
|
||||
var device = await _accessDeviceRepo.GetDeviceByDId(requested.FKDeviceId);
|
||||
var customer = await _accessDeviceRepo
|
||||
.GetCustomerByIdAsync(requested.FKCustomerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await EnsureAccessProfileAsync(
|
||||
device.FKClientId, customer.FKUserId, "Customer",
|
||||
requested.Name ?? customer.CusName ?? string.Empty, queueProvisioning: false);
|
||||
var binding = await EnsureBindingAsync(profile, device);
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
await _UoW.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
accessNumber = binding.DeviceEmployeeNo,
|
||||
Message = "Access user queued for the on-prem agent."
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> EnsureAccessProfileAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
string personType,
|
||||
string displayName,
|
||||
bool queueProvisioning = true)
|
||||
{
|
||||
var profile = await _accessDeviceRepo.GetAccessProfileAsync(clientId, userId);
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
profile = new AccessProfile
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKUserId = userId,
|
||||
PersonType = personType,
|
||||
DisplayName = displayName,
|
||||
DesiredAccessState = "Enabled",
|
||||
DesiredVersion = 1,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddAccessProfileAsync(profile);
|
||||
// AccessProfileId is the stable numeric terminal employee number.
|
||||
await _UoW.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.PersonType = personType;
|
||||
profile.DisplayName = displayName;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (queueProvisioning)
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
public async Task ProvisionProfilesToDeviceAsync(AccessDevice device)
|
||||
{
|
||||
var profiles = await _accessDeviceRepo
|
||||
.GetAccessProfilesByClientAsync(device.FKClientId);
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
var binding = await EnsureBindingAsync(profile, device);
|
||||
if (binding.ProvisionStatus != "Synced")
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessProfileAsync(clientId, userId)
|
||||
?? throw new NotFoundException("No access profile exists for this user at this gym.");
|
||||
|
||||
profile.DesiredAccessState = enabled ? "Enabled" : "Disabled";
|
||||
profile.DesiredVersion++;
|
||||
profile.LockReason = enabled ? null : reason;
|
||||
profile.LockedAt = enabled ? null : DateTime.UtcNow;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Include terminals added after the person was originally onboarded.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
foreach (var binding in profile.DeviceBindings)
|
||||
{
|
||||
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
|
||||
continue;
|
||||
binding.ProvisionStatus = "Pending";
|
||||
binding.LastError = null;
|
||||
binding.UpdatedAt = DateTime.UtcNow;
|
||||
await SupersedePendingStateCommandsAsync(binding.AccessDeviceBindingId);
|
||||
await QueueProfileCommandAsync(profile, binding, "SetAccessState");
|
||||
}
|
||||
|
||||
await _UoW.CommitAsync();
|
||||
return (await GetAccessStatusAsync(clientId, userId))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetCustomerAccessAsync(
|
||||
Guid clientId, Guid customerId, bool enabled, string? reason)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
var status = await SetUserAccessAsync(clientId, customer.FKUserId, enabled, reason);
|
||||
|
||||
// A couple/group plan is one shared entitlement, so the whole party follows the
|
||||
// member that was acted on -- locking only the primary would leave the partner
|
||||
// badging in on a plan that is locked, and unlocking only the primary would leave
|
||||
// the partner stranded after the reason for the lock is gone.
|
||||
var coMemberIds = await _accessDeviceRepo
|
||||
.GetCoMembersOnSharedSubscriptionsAsync(clientId, customerId);
|
||||
|
||||
foreach (var coMemberId in coMemberIds)
|
||||
{
|
||||
var coMember = await _accessDeviceRepo.GetCustomerByIdAsync(coMemberId);
|
||||
if (coMember == null)
|
||||
continue;
|
||||
|
||||
// Best-effort: a co-member without an access profile yet (never onboarded to
|
||||
// a device) must not fail the lock that was actually requested.
|
||||
try
|
||||
{
|
||||
await SetUserAccessAsync(clientId, coMember.FKUserId, enabled, reason);
|
||||
}
|
||||
catch (NotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<object> SetCustomerFacePhotoAsync(
|
||||
Guid clientId, Guid customerId, string base64Jpeg)
|
||||
{
|
||||
// Validate before opening a transaction: a rejected photo should cost nothing.
|
||||
var photo = FacePhotoValidator.ValidateBase64(base64Jpeg);
|
||||
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessProfileAsync(clientId, customer.FKUserId)
|
||||
?? throw new NotFoundException(
|
||||
"No access profile exists for this member at this gym.");
|
||||
|
||||
profile.FacePhotoStatus = "Pending";
|
||||
profile.FacePhotoError = null;
|
||||
profile.FacePhotoUpdatedAt = DateTime.UtcNow;
|
||||
profile.DesiredVersion++;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Include terminals added after the member was originally onboarded.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
var queued = 0;
|
||||
var payload = Convert.ToBase64String(photo.Bytes);
|
||||
foreach (var binding in profile.DeviceBindings)
|
||||
{
|
||||
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
|
||||
continue;
|
||||
await SupersedePendingFacePhotoCommandsAsync(binding.AccessDeviceBindingId);
|
||||
await QueueFacePhotoCommandAsync(profile, binding, payload);
|
||||
queued++;
|
||||
}
|
||||
|
||||
if (queued == 0)
|
||||
{
|
||||
// Nothing can carry the photo to a terminal, so don't leave the member
|
||||
// sitting on "Pending" forever with no command in flight.
|
||||
profile.FacePhotoStatus = "Failed";
|
||||
profile.FacePhotoError =
|
||||
"No reachable access terminal is currently paired with this gym.";
|
||||
}
|
||||
|
||||
await _UoW.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
facePhotoStatus = profile.FacePhotoStatus,
|
||||
queuedToDevices = queued,
|
||||
width = photo.Width,
|
||||
height = photo.Height,
|
||||
sizeBytes = photo.Bytes.Length
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept separate from QueueProfileCommandAsync: the face payload is large and is
|
||||
// deliberately purged after the terminal confirms, which the person/state commands
|
||||
// must never be.
|
||||
private async Task QueueFacePhotoCommandAsync(
|
||||
AccessProfile profile, AccessDeviceBinding binding, string base64Jpeg)
|
||||
{
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
bindingId = binding.AccessDeviceBindingId,
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
employeeNo = binding.DeviceEmployeeNo,
|
||||
name = profile.DisplayName,
|
||||
faceJpegBase64 = base64Jpeg,
|
||||
desiredVersion = profile.DesiredVersion
|
||||
});
|
||||
|
||||
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = profile.FKClientId,
|
||||
FKDeviceId = binding.FKDeviceId,
|
||||
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
||||
CommandType = "UpsertFacePhoto",
|
||||
PayloadJson = payloadJson,
|
||||
DesiredVersion = profile.DesiredVersion,
|
||||
Status = "Pending",
|
||||
MaxAttempts = 3,
|
||||
NextAttemptAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// A newer photo replaces an older one that has not reached the device yet, so the
|
||||
// stale payload is dropped rather than left queued (and holding an image).
|
||||
private async Task SupersedePendingFacePhotoCommandsAsync(Guid bindingId)
|
||||
{
|
||||
var stale = await _deviceCommandRepo
|
||||
.GetPendingFacePhotoCommandsAsync(bindingId);
|
||||
foreach (var command in stale)
|
||||
{
|
||||
command.Status = "Superseded";
|
||||
command.PayloadJson = string.Empty;
|
||||
command.ProcessedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetStaffAccessAsync(
|
||||
Guid clientId, Guid staffId, bool enabled, string? reason)
|
||||
{
|
||||
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
|
||||
?? throw new NotFoundException("Staff member not found in this gym.");
|
||||
return await SetUserAccessAsync(clientId, staff.FKUserId, enabled, reason);
|
||||
}
|
||||
|
||||
public async Task<bool> WhiteListMember(Guid customerId)
|
||||
{
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> BlackListMember(Guid customerId)
|
||||
{
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: false, reason: "Manual");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> LockCustomer(Guid customerId, string reason)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return false;
|
||||
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: false, reason);
|
||||
|
||||
customer.IsLocked = true;
|
||||
customer.LockReason = reason;
|
||||
customer.LockedAt = DateTime.UtcNow;
|
||||
await _UoW.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UnlockCustomer(Guid customerId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return false;
|
||||
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
|
||||
|
||||
customer.IsLocked = false;
|
||||
customer.LockReason = null;
|
||||
customer.LockedAt = null;
|
||||
await _UoW.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<Customer> UpdateDeviceProfile(Customer customer) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public async Task<int> SaveAttendanceRecords(Guid clientId, List<AttendanceDTO> attendanceList)
|
||||
{
|
||||
if (attendanceList.Count == 0)
|
||||
return 0;
|
||||
|
||||
// The source key is stable across JSON DateTime kind conversion and
|
||||
// database round-trips. It also gives the database a final idempotency
|
||||
// guard when an agent retries after an ambiguous HTTP response.
|
||||
var keyedEvents = attendanceList
|
||||
.OrderBy(a => a.EventTime)
|
||||
.Select(dto => new
|
||||
{
|
||||
Dto = dto,
|
||||
SourceEventKey = BuildSourceEventKey(
|
||||
clientId, dto.DeviceId, dto.DeviceEmployeeNo, dto.EventTime, dto.EventType)
|
||||
})
|
||||
.ToList();
|
||||
var candidateKeys = keyedEvents
|
||||
.Select(e => e.SourceEventKey)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var existingKeys = await _attendanceRepo.GetSourceEventKeysAsync(candidateKeys);
|
||||
|
||||
var newRecords = new List<Attendance>();
|
||||
var seenKeys = new HashSet<string>(existingKeys);
|
||||
foreach (var keyedEvent in keyedEvents)
|
||||
{
|
||||
if (!seenKeys.Add(keyedEvent.SourceEventKey))
|
||||
continue;
|
||||
|
||||
var dto = keyedEvent.Dto;
|
||||
newRecords.Add(new Attendance
|
||||
{
|
||||
AttendanceId = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
FKDeviceId = dto.DeviceId,
|
||||
FKCustomerId = dto.CustomerId,
|
||||
FKUserId = dto.UserId,
|
||||
FKAccessProfileId = dto.AccessProfileId,
|
||||
DeviceUserId = dto.DeviceUserId,
|
||||
DeviceEmployeeNo = dto.DeviceEmployeeNo,
|
||||
SourceEventKey = keyedEvent.SourceEventKey,
|
||||
EventTime = dto.EventTime,
|
||||
EventType = dto.EventType
|
||||
});
|
||||
}
|
||||
|
||||
if (newRecords.Count == 0)
|
||||
return 0;
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _attendanceRepo.AddAttendancesAsync(newRecords);
|
||||
await _UoW.CommitAsync();
|
||||
return newRecords.Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
// The agent advances its per-device watermark only after an HTTP success.
|
||||
// Surface persistence failures so it retries instead of losing events.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<UnlinkedDeviceEnrollment>> GetUnlinkedFingerprintsAsync(Guid clientId) =>
|
||||
_accessDeviceRepo.GetUnlinkedEnrollmentsByClientAsync(clientId);
|
||||
|
||||
public async Task<AccessProfile> LinkFingerprintAsync(
|
||||
Guid clientId, Guid customerId, Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
return await LinkDevicePersonAsync(
|
||||
clientId, customer.FKUserId, "Customer",
|
||||
customer.CusName ?? string.Empty, unlinkedEnrollmentId);
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> LinkDevicePersonAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
string personType,
|
||||
string displayName,
|
||||
Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var enrollment = await _accessDeviceRepo
|
||||
.GetUnlinkedEnrollmentByIdAsync(unlinkedEnrollmentId)
|
||||
?? throw new NotFoundException("Unlinked device person not found.");
|
||||
|
||||
if (enrollment.LinkedAt != null)
|
||||
throw new BadRequestException("This device person has already been linked.");
|
||||
if (enrollment.AccessDevice.FKClientId != clientId)
|
||||
throw new ForbiddenException("This device person does not belong to your gym.");
|
||||
|
||||
var employeeNo = NormalizeEmployeeNo(enrollment);
|
||||
var occupied = await _accessDeviceRepo.IsDeviceEmployeeNoOccupiedAsync(
|
||||
enrollment.FKDeviceId, employeeNo, userId);
|
||||
if (occupied)
|
||||
throw new BadRequestException($"Device user {employeeNo} is already linked to another person.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await EnsureAccessProfileAsync(
|
||||
clientId, userId, personType, displayName, queueProvisioning: false);
|
||||
var binding = await _accessDeviceRepo.GetBindingAsync(
|
||||
profile.AccessProfileId, enrollment.FKDeviceId);
|
||||
|
||||
if (binding == null)
|
||||
{
|
||||
binding = new AccessDeviceBinding
|
||||
{
|
||||
AccessDeviceBindingId = Guid.NewGuid(),
|
||||
FKAccessProfileId = profile.AccessProfileId,
|
||||
FKDeviceId = enrollment.FKDeviceId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddBindingAsync(binding);
|
||||
profile.DeviceBindings.Add(binding);
|
||||
}
|
||||
|
||||
binding.DeviceEmployeeNo = employeeNo;
|
||||
binding.DeviceReportedName = enrollment.DeviceReportedName;
|
||||
binding.FingerprintCount = enrollment.FingerprintCount;
|
||||
binding.ProvisionStatus = "Synced";
|
||||
binding.ActualAccessState = IsBlocked(enrollment.DeviceUserType) ? "Disabled" : "Enabled";
|
||||
binding.LastSyncedAt = DateTime.UtcNow;
|
||||
binding.LastError = null;
|
||||
binding.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var linkedAt = DateTime.UtcNow;
|
||||
var duplicateDiscoveries =
|
||||
await _accessDeviceRepo.GetDuplicateUnlinkedEnrollmentsAsync(
|
||||
enrollment.FKDeviceId,
|
||||
employeeNo,
|
||||
enrollment.DeviceUserId);
|
||||
foreach (var discovered in duplicateDiscoveries)
|
||||
{
|
||||
discovered.LinkedAt = linkedAt;
|
||||
discovered.FKAccessProfileId = profile.AccessProfileId;
|
||||
}
|
||||
|
||||
// Reconcile all other active terminals for the same gym.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
await _UoW.CommitAsync();
|
||||
return profile;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> LinkStaffFingerprintAsync(
|
||||
Guid clientId, Guid staffId, Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
|
||||
?? throw new NotFoundException("Staff member not found in this gym.");
|
||||
return await LinkDevicePersonAsync(
|
||||
clientId, staff.FKUserId, "Staff",
|
||||
staff.User?.UserName ?? string.Empty, unlinkedEnrollmentId);
|
||||
}
|
||||
|
||||
public async Task<AccessStatusDto?> GetAccessStatusAsync(Guid clientId, Guid userId)
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessStatusProfileAsync(clientId, userId);
|
||||
if (profile == null)
|
||||
return null;
|
||||
|
||||
var bindings = profile.DeviceBindings
|
||||
.Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null)
|
||||
.ToList();
|
||||
var provisionStatus = bindings.Count == 0
|
||||
? "NoDevice"
|
||||
: bindings.Any(b => b.ProvisionStatus == "Failed")
|
||||
? "Failed"
|
||||
: bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending";
|
||||
var desired = profile.DesiredAccessState;
|
||||
var actual = bindings.Count == 0
|
||||
? "Unknown"
|
||||
: bindings.All(b => b.ActualAccessState == desired) ? desired
|
||||
: bindings.All(b => b.ActualAccessState == "Unknown") ? "Unknown" : "Mixed";
|
||||
|
||||
return new AccessStatusDto
|
||||
{
|
||||
AccessProfileId = profile.AccessProfileId,
|
||||
AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo
|
||||
?? $"HF{profile.AccessProfileId}",
|
||||
PersonType = profile.PersonType,
|
||||
DesiredAccessState = desired,
|
||||
ProvisionStatus = provisionStatus,
|
||||
ActualAccessState = actual,
|
||||
FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount),
|
||||
LastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError,
|
||||
LastSyncedAt = bindings.Max(b => b.LastSyncedAt),
|
||||
DeviceCount = bindings.Count,
|
||||
SyncedDeviceCount = bindings.Count(b => b.ProvisionStatus == "Synced")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task QueueProvisioningForActiveDevicesAsync(AccessProfile profile)
|
||||
{
|
||||
var devices = (await _accessDeviceRepo
|
||||
.GetDevicesByClientAsync(profile.FKClientId))
|
||||
.Where(device => device.FKAgentId != null)
|
||||
.ToList();
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
|
||||
?? await EnsureBindingAsync(profile, device);
|
||||
if (binding.ProvisionStatus != "Synced")
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AccessDeviceBinding> EnsureBindingAsync(AccessProfile profile, AccessDevice device)
|
||||
{
|
||||
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
|
||||
?? await _accessDeviceRepo.GetBindingAsync(
|
||||
profile.AccessProfileId, device.DeviceId);
|
||||
if (binding != null)
|
||||
return binding;
|
||||
|
||||
binding = new AccessDeviceBinding
|
||||
{
|
||||
AccessDeviceBindingId = Guid.NewGuid(),
|
||||
FKAccessProfileId = profile.AccessProfileId,
|
||||
FKDeviceId = device.DeviceId,
|
||||
// Namespace platform-managed terminal users away from locally-created
|
||||
// numeric ids, which prevents overwriting an unlinked person by collision.
|
||||
DeviceEmployeeNo = $"HF{profile.AccessProfileId}",
|
||||
ProvisionStatus = "Pending",
|
||||
ActualAccessState = "Unknown",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddBindingAsync(binding);
|
||||
profile.DeviceBindings.Add(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private async Task QueueProfileCommandAsync(
|
||||
AccessProfile profile, AccessDeviceBinding binding, string commandType)
|
||||
{
|
||||
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
|
||||
binding.AccessDeviceBindingId,
|
||||
commandType,
|
||||
profile.DesiredVersion);
|
||||
if (alreadyQueued)
|
||||
return;
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
bindingId = binding.AccessDeviceBindingId,
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
employeeNo = binding.DeviceEmployeeNo,
|
||||
name = profile.DisplayName,
|
||||
enabled = profile.DesiredAccessState == "Enabled",
|
||||
desiredVersion = profile.DesiredVersion
|
||||
});
|
||||
|
||||
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = profile.FKClientId,
|
||||
FKDeviceId = binding.FKDeviceId,
|
||||
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
||||
CommandType = commandType,
|
||||
PayloadJson = payloadJson,
|
||||
DesiredVersion = profile.DesiredVersion,
|
||||
Status = "Pending",
|
||||
MaxAttempts = 5,
|
||||
NextAttemptAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SupersedePendingStateCommandsAsync(Guid bindingId)
|
||||
{
|
||||
var stale = await _deviceCommandRepo
|
||||
.GetPendingStateCommandsAsync(bindingId);
|
||||
foreach (var command in stale)
|
||||
{
|
||||
command.Status = "Superseded";
|
||||
command.ProcessedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<bool> IsDeviceCommandableAsync(Guid deviceId) =>
|
||||
_accessDeviceRepo.IsDeviceCommandableAsync(deviceId);
|
||||
|
||||
private async Task<(Guid clientId, Guid userId)> ResolveCustomerAccessOwnerAsync(Guid customerId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
var clientId = await _accessDeviceRepo
|
||||
.GetLatestActiveClientIdForCustomerAsync(customerId);
|
||||
if (!clientId.HasValue)
|
||||
throw new NotFoundException("Customer has no active gym membership.");
|
||||
return (clientId.Value, customer.FKUserId);
|
||||
}
|
||||
|
||||
private static string NormalizeEmployeeNo(UnlinkedDeviceEnrollment enrollment) =>
|
||||
!string.IsNullOrWhiteSpace(enrollment.DeviceEmployeeNo)
|
||||
? enrollment.DeviceEmployeeNo
|
||||
: enrollment.DeviceUserId.ToString();
|
||||
|
||||
private static bool IsBlocked(string? userType) =>
|
||||
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string BuildSourceEventKey(
|
||||
Guid clientId,
|
||||
Guid deviceId,
|
||||
string deviceEmployeeNo,
|
||||
DateTime eventTime,
|
||||
string eventType)
|
||||
{
|
||||
// MySQL DATETIME does not preserve DateTime.Kind. Formatting only the
|
||||
// stored wall-clock fields keeps this key identical after reading it back.
|
||||
var normalizedTime = eventTime.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.ffffff", CultureInfo.InvariantCulture);
|
||||
var source = string.Join(
|
||||
"|",
|
||||
clientId.ToString("D"),
|
||||
deviceId.ToString("D"),
|
||||
deviceEmployeeNo.Trim(),
|
||||
normalizedTime,
|
||||
eventType.Trim());
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GenerateShortCode(int length = 8)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var bytes = RandomNumberGenerator.GetBytes(length);
|
||||
var chars = new char[length];
|
||||
for (var i = 0; i < length; i++)
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.ActivityLog
|
||||
{
|
||||
public class ActivityLogService : IActivityLogService
|
||||
{
|
||||
private readonly IActivityLogRepository _activityLogRepository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ActivityLogService(
|
||||
IActivityLogRepository activityLogRepository,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_activityLogRepository = activityLogRepository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
// To do: add services used, IP addresses, category as Error/ Action
|
||||
|
||||
public async Task<object> LogActivity(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
string description = data["description"].GetString()!;
|
||||
|
||||
// Get userId from HttpContext ( for testing purposes - hardcoded)
|
||||
Guid userId = Guid.Parse("08de9f89-4681-4a2f-8f37-6c1ce830ee6f");
|
||||
// Guid userId = Guid.Parse(httpContext.Items["UserId"]?.ToString()!);
|
||||
|
||||
Guid? adminId = await _userRoleHelper.GetAdminId(userId);
|
||||
bool isAdmin = adminId.HasValue;
|
||||
|
||||
// Get user details (you might need to add a method to get user by Id)
|
||||
var userName = await _activityLogRepository.GetUserNameAsync(userId);
|
||||
|
||||
var log = new Fitness_core_BE.Models.ActivityLog
|
||||
{
|
||||
FKUserId = userId,
|
||||
UserName = userName,
|
||||
IsAdmin = isAdmin,
|
||||
Description = description,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _activityLogRepository.AddActivityLogAsync(log);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return log;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
Console.WriteLine($"Activity log failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetActivityLogs(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
string searchTerm = data.ContainsKey("searchTerm") && !string.IsNullOrEmpty(data["searchTerm"].GetString())
|
||||
? data["searchTerm"].GetString()
|
||||
: null;
|
||||
|
||||
DateTime? startDate = data.ContainsKey("startDate") && !string.IsNullOrEmpty(data["startDate"].GetString())
|
||||
? DateTime.Parse(data["startDate"].GetString()!)
|
||||
: null;
|
||||
|
||||
DateTime? endDate = data.ContainsKey("endDate") && !string.IsNullOrEmpty(data["endDate"].GetString())
|
||||
? DateTime.Parse(data["endDate"].GetString()!)
|
||||
: null;
|
||||
|
||||
var logs = await _activityLogRepository.GetActivityLogsAsync(
|
||||
searchTerm,
|
||||
startDate,
|
||||
endDate);
|
||||
|
||||
return logs;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
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.Security.Cryptography;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class AdminClientDirectoryService : IAdminClientDirectoryService
|
||||
{
|
||||
private readonly IAdminClientDirectoryRepository _repository;
|
||||
private readonly IAgentRepo _agentRepo;
|
||||
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
||||
private readonly IPairingCodeRepo _pairingCodeRepo;
|
||||
private readonly IUnitOfWork _UoW;
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
// Three missed 30-second health heartbeats marks an agent/device offline.
|
||||
// Legacy agents without explicit health reports retain the old grace window.
|
||||
private static readonly TimeSpan HealthOnlineWindow = TimeSpan.FromSeconds(90);
|
||||
private static readonly TimeSpan LegacyOnlineWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
public AdminClientDirectoryService(
|
||||
IAdminClientDirectoryRepository repository,
|
||||
IAgentRepo agentRepo,
|
||||
IAccessDeviceRepo accessDeviceRepo,
|
||||
IPairingCodeRepo pairingCodeRepo,
|
||||
IUnitOfWork unitOfWork,
|
||||
IAuthService authService)
|
||||
{
|
||||
_repository = repository;
|
||||
_agentRepo = agentRepo;
|
||||
_accessDeviceRepo = accessDeviceRepo;
|
||||
_pairingCodeRepo = pairingCodeRepo;
|
||||
_UoW = unitOfWork;
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ClientSummary>> ListAsync(ClientDirectoryQuery query)
|
||||
{
|
||||
var (items, total) = await _repository.ListAsync(query.Search, query.Page, query.PageSize);
|
||||
|
||||
var summaries = items.Select(c => new ClientSummary(
|
||||
c.ClientId,
|
||||
c.ClientName,
|
||||
c.ClientBusinesses.FirstOrDefault()?.City,
|
||||
c.PaymentTrack?.ToString(),
|
||||
c.ClientBusinesses.Any(b => b.IsActive),
|
||||
c.ClientBusinesses.OrderBy(b => b.createdAt).FirstOrDefault()?.createdAt ?? DateTime.MinValue
|
||||
)).ToList();
|
||||
|
||||
return new PagedResult<ClientSummary>(summaries, total, query.Page, query.PageSize);
|
||||
}
|
||||
|
||||
public async Task<object> GetByIdAsync(Guid id)
|
||||
{
|
||||
var client = await _repository.GetByIdAsync(id)
|
||||
?? throw new NotFoundException("Client not found.");
|
||||
|
||||
return new
|
||||
{
|
||||
client.ClientId,
|
||||
client.ClientName,
|
||||
client.ClientPhone,
|
||||
client.ClientEmail,
|
||||
PaymentTrack = client.PaymentTrack?.ToString(),
|
||||
Businesses = client.ClientBusinesses.Select(b => new
|
||||
{
|
||||
b.Bid,
|
||||
b.BusinessName,
|
||||
b.Address,
|
||||
b.City,
|
||||
b.IsActive
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ResendWelcomeEmailAsync(Guid clientId, HttpContext httpContext)
|
||||
{
|
||||
var client = await _repository.GetByIdAsync(clientId)
|
||||
?? throw new NotFoundException("Client not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(client.ClientEmail))
|
||||
throw new BadRequestException("This client has no email on file to send the link to.");
|
||||
|
||||
var authResponse = await _authService.ForgotPassword(
|
||||
new { identifier = client.ClientEmail, useResetLink = true, welcome = true },
|
||||
httpContext);
|
||||
|
||||
if (authResponse.StatusCode != 200 || !authResponse.Success)
|
||||
throw new BadRequestException(authResponse.Message ?? "Couldn't resend the welcome email.");
|
||||
}
|
||||
|
||||
public async Task<PairingCodeResult> GeneratePairingCodeAsync(Guid clientId)
|
||||
{
|
||||
if (!await _repository.ClientExistsAsync(clientId))
|
||||
throw new NotFoundException("Client not found.");
|
||||
|
||||
var pairingCode = new PairingCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = GenerateShortCode(),
|
||||
FKClientId = clientId,
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _pairingCodeRepo.AddAsync(pairingCode);
|
||||
await _UoW.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new PairingCodeResult(pairingCode.Code, pairingCode.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<AdminClientDevicesResult> GetDevicesAsync(Guid clientId)
|
||||
{
|
||||
if (!await _repository.ClientExistsAsync(clientId))
|
||||
throw new NotFoundException("Client not found.");
|
||||
|
||||
var agents = await _agentRepo.GetByClientIdAsync(clientId);
|
||||
var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeAgentIds = agents
|
||||
.Where(a => a.IsActive)
|
||||
.Select(a => a.AgentId)
|
||||
.ToHashSet();
|
||||
|
||||
var agentDtos = agents
|
||||
.Select(agent => new AdminAgentDto(
|
||||
agent.AgentId,
|
||||
agent.Name,
|
||||
agent.IsActive,
|
||||
agent.LastSeenAt,
|
||||
agent.IsActive && agent.LastSeenAt.HasValue &&
|
||||
now - agent.LastSeenAt.Value <= HealthOnlineWindow,
|
||||
agent.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
var deviceDtos = devices
|
||||
.Select(device =>
|
||||
{
|
||||
var hasHealthReport = device.LastHealthReportedAt.HasValue;
|
||||
var presenceTimestamp = hasHealthReport
|
||||
? device.LastHealthReportedAt
|
||||
: device.LastSeenAt;
|
||||
var window = hasHealthReport
|
||||
? HealthOnlineWindow
|
||||
: LegacyOnlineWindow;
|
||||
var isOnline =
|
||||
device.IsActive &&
|
||||
device.FKAgentId.HasValue &&
|
||||
activeAgentIds.Contains(device.FKAgentId.Value) &&
|
||||
presenceTimestamp.HasValue &&
|
||||
now - presenceTimestamp.Value <= window &&
|
||||
(!hasHealthReport || device.IsReachable);
|
||||
|
||||
return new AdminDeviceDto(
|
||||
device.DeviceId,
|
||||
device.DeviceName,
|
||||
device.IsActive,
|
||||
device.LastSeenAt,
|
||||
isOnline,
|
||||
isOnline ? presenceTimestamp!.Value.Add(window) : null,
|
||||
device.FKAgentId.HasValue,
|
||||
device.CreatedAt);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new AdminClientDevicesResult(agentDtos, deviceDtos);
|
||||
}
|
||||
|
||||
private static string GenerateShortCode(int length = 8)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var bytes = RandomNumberGenerator.GetBytes(length);
|
||||
var chars = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
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;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class AdminSuggestionService : IAdminSuggestionService
|
||||
{
|
||||
private readonly IAdminSuggestionRepository _repository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
public AdminSuggestionService(IAdminSuggestionRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow) => (_repository, _userRoleHelper, _uow) = (repository, userRoleHelper, uow);
|
||||
public async Task<object> ListAsync()
|
||||
{
|
||||
var (workouts, meals) = await _repository.GetPublishedAsync();
|
||||
return new { workouts, meals };
|
||||
}
|
||||
public async Task<object> CreateWorkoutAsync(AdminWorkoutSuggestionRequest request, HttpContext httpContext)
|
||||
{
|
||||
await EnsureAdminAsync(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Days.Count == 0) throw new BadRequestException("Name and at least one workout day are required.");
|
||||
await _uow.BeginAsync();
|
||||
try {
|
||||
var category = await GetWorkoutCategoryAsync(request.Category, request.CategoryDescription);
|
||||
var plan = new WorkoutPlan { WorkoutPlanId = Guid.NewGuid(), WorkoutPlanName = request.Name.Trim(), Description = request.Description, FKWorkoutCategoryId = category.WorkoutCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow };
|
||||
foreach (var day in request.Days.Where(d => !string.IsNullOrWhiteSpace(d.Day)))
|
||||
{
|
||||
var item = new WorkoutItem { WorkoutItemId = Guid.NewGuid(), ExerciseDay = day.Day.Trim(), Description = day.Description, Exercises = JsonSerializer.Serialize(day.Exercises), HowToDo = JsonSerializer.Serialize(new { suitableFor = day.SuitableFor, exercises = day.Exercises }), Duration = day.Duration, WormUp = day.WarmUp, Cooldown = day.Cooldown, IsActive = true, CreateDate = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
var order = 1;
|
||||
foreach (var exercise in day.Exercises.Where(e => !string.IsNullOrWhiteSpace(e.Name)))
|
||||
{
|
||||
item.WorkoutExercises.Add(new WorkoutExercise { WorkoutExerciseId = Guid.NewGuid(), Name = exercise.Name.Trim(), MuscleGroup = exercise.Position, Sets = exercise.Sets, Reps = exercise.Reps, Description = exercise.Description, BreathIn = exercise.BreathIn, BreathOut = exercise.BreathOut, VideoUrl = exercise.VideoUrl, SortOrder = order++, IsActive = true, CreateDate = DateTime.UtcNow });
|
||||
}
|
||||
plan.WorkoutItems.Add(item);
|
||||
}
|
||||
await _repository.AddWorkoutAsync(plan); await _uow.CommitAsync(); return new { plan.WorkoutPlanId, numberOfDays = plan.WorkoutItems.Count };
|
||||
} catch { await _uow.RollbackAsync(); throw; }
|
||||
}
|
||||
public async Task<object> CreateMealAsync(AdminMealSuggestionRequest request, HttpContext httpContext)
|
||||
{
|
||||
await EnsureAdminAsync(httpContext);
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Meals.Count == 0) throw new BadRequestException("Name and at least one meal are required.");
|
||||
await _uow.BeginAsync();
|
||||
try {
|
||||
var category = await GetMealCategoryAsync(request.Category, request.CategoryDescription);
|
||||
var plan = new MealPlan { MealPlanId = Guid.NewGuid(), Name = request.Name.Trim(), Description = request.Description, FKMealCategoryId = category.MealCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow };
|
||||
foreach (var meal in request.Meals.Where(m => !string.IsNullOrWhiteSpace(m.Name))) plan.MealItems.Add(new Models.Meal { MealId = Guid.NewGuid(), Name = meal.Name.Trim(), Description = meal.Description, Calories = meal.Calories, Protein = meal.Protein, Carbohydrates = meal.Carbohydrates, Fats = meal.Fats, ServingSize = meal.ServingSize, MealItems = JsonSerializer.Serialize(meal.Foods), PreparationNotes = meal.PreparationNotes, IsActive = true, CreateDate = DateTime.UtcNow });
|
||||
await _repository.AddMealAsync(plan); await _uow.CommitAsync(); return new { plan.MealPlanId, mealCount = plan.MealItems.Count };
|
||||
} catch { await _uow.RollbackAsync(); throw; }
|
||||
}
|
||||
private async Task EnsureAdminAsync(HttpContext context) { var id = DataExtractor.GetUserIdFromContext(context); if ((await _userRoleHelper.GetAdminId(id).ConfigureAwait(false)) is not Guid) throw new ForbiddenException("Only admins can manage platform suggestions."); }
|
||||
private async Task<WorkoutCategory> GetWorkoutCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "General Fitness" : name.Trim(); var category = await _repository.FindWorkoutCategoryAsync(name); if (category != null) return category; category = new WorkoutCategory { WorkoutCategoryId = Guid.NewGuid(), WorkoutCategoryName = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddWorkoutCategoryAsync(category); return category; }
|
||||
private async Task<MealCategory> GetMealCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "Balanced Nutrition" : name.Trim(); var category = await _repository.FindMealCategoryAsync(name); if (category != null) return category; category = new MealCategory { MealCategoryId = Guid.NewGuid(), Name = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddMealCategoryAsync(category); return category; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
using Fitness_core_BE.DTOs;
|
||||
using Fitness_core_BE.DTOs.Agent;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Hubs;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class AgentService : IAgentService
|
||||
{
|
||||
private static readonly TimeSpan DevicePresenceWindow = TimeSpan.FromSeconds(90);
|
||||
|
||||
private readonly IUnitOfWork _UoW;
|
||||
private readonly IAgentRepo _agentRepo;
|
||||
private readonly IPairingCodeRepo _pairingCodeRepo;
|
||||
private readonly IDeviceCommandRepo _deviceCommandRepo;
|
||||
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
||||
private readonly IAccessDeviceService _accessDeviceService;
|
||||
private readonly ILogger<AgentService> _logger;
|
||||
private readonly IHubContext<AccessHub> _accessHub;
|
||||
|
||||
public AgentService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAgentRepo agentRepo,
|
||||
IPairingCodeRepo pairingCodeRepo,
|
||||
IDeviceCommandRepo deviceCommandRepo,
|
||||
IAccessDeviceRepo accessDeviceRepo,
|
||||
IAccessDeviceService accessDeviceService,
|
||||
ILogger<AgentService> logger,
|
||||
IHubContext<AccessHub> accessHub)
|
||||
{
|
||||
_UoW = unitOfWork;
|
||||
_agentRepo = agentRepo;
|
||||
_pairingCodeRepo = pairingCodeRepo;
|
||||
_deviceCommandRepo = deviceCommandRepo;
|
||||
_accessDeviceRepo = accessDeviceRepo;
|
||||
_accessDeviceService = accessDeviceService;
|
||||
_logger = logger;
|
||||
_accessHub = accessHub;
|
||||
}
|
||||
|
||||
public async Task<object> EnrollAsync(EnrollRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Code))
|
||||
throw new BadRequestException("Pairing code is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.AgentName))
|
||||
throw new BadRequestException("Agent name is required.");
|
||||
|
||||
var pairingCode = await _pairingCodeRepo.GetActiveByCodeAsync(request.Code);
|
||||
if (pairingCode == null)
|
||||
throw new BadRequestException("Pairing code is invalid, used, or expired.");
|
||||
|
||||
var apiKey = ApiKeyHelper.GenerateApiKey();
|
||||
var agent = new Agent
|
||||
{
|
||||
AgentId = Guid.NewGuid(),
|
||||
FKClientId = pairingCode.FKClientId,
|
||||
Name = request.AgentName.Trim(),
|
||||
ApiKeyHash = ApiKeyHelper.HashApiKey(apiKey),
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _agentRepo.AddAsync(agent);
|
||||
await _pairingCodeRepo.MarkUsedAsync(pairingCode);
|
||||
await _UoW.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
return new { agentId = agent.AgentId, apiKey };
|
||||
}
|
||||
|
||||
public async Task<object> RegisterDeviceAsync(
|
||||
Guid clientId, Guid agentId, RegisterDeviceRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.DeviceName))
|
||||
throw new BadRequestException("Device name is required.");
|
||||
|
||||
var hardwareId = string.IsNullOrWhiteSpace(request.HardwareId)
|
||||
? null
|
||||
: request.HardwareId.Trim();
|
||||
AccessDevice? device = null;
|
||||
|
||||
if (request.DeviceId.HasValue && request.DeviceId.Value != Guid.Empty)
|
||||
{
|
||||
device = await _accessDeviceRepo.GetDeviceByIdAsync(request.DeviceId.Value);
|
||||
if (device != null && device.FKClientId != clientId)
|
||||
throw new ForbiddenException("Device belongs to another client.");
|
||||
}
|
||||
|
||||
if (device == null && hardwareId != null)
|
||||
{
|
||||
device = await _accessDeviceRepo
|
||||
.GetDeviceByHardwareIdAsync(clientId, hardwareId);
|
||||
}
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
if (device == null)
|
||||
{
|
||||
device = new AccessDevice
|
||||
{
|
||||
DeviceId = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddDeviceAsync(device);
|
||||
}
|
||||
|
||||
device.DeviceName = request.DeviceName.Trim();
|
||||
device.HardwareId = hardwareId ?? device.HardwareId;
|
||||
device.FKAgentId = agentId;
|
||||
device.IsActive = true;
|
||||
device.LastSeenAt = DateTime.UtcNow;
|
||||
|
||||
// The row must exist before bindings/commands reference it.
|
||||
await _UoW.SaveChangesAsync();
|
||||
await _accessDeviceService.ProvisionProfilesToDeviceAsync(device);
|
||||
await _UoW.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new { deviceId = device.DeviceId };
|
||||
}
|
||||
|
||||
public async Task<object> ReconcileDeviceInventoryAsync(
|
||||
Guid clientId, Guid agentId, DeviceInventoryRequest request)
|
||||
{
|
||||
if (request == null || request.DeviceId == Guid.Empty)
|
||||
throw new BadRequestException("Device id is required.");
|
||||
|
||||
var device = await _accessDeviceRepo
|
||||
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
|
||||
if (device == null)
|
||||
throw new ForbiddenException("Device does not belong to this client.");
|
||||
if (device.FKAgentId != agentId)
|
||||
throw new ForbiddenException("Device is assigned to another agent.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var discovered = 0;
|
||||
var updated = 0;
|
||||
var reportedEmployeeNos = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
device.LastSeenAt = now;
|
||||
foreach (var person in request.People ?? new List<DeviceInventoryPerson>())
|
||||
{
|
||||
var employeeNo = person.EmployeeNo?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(employeeNo))
|
||||
continue;
|
||||
reportedEmployeeNos.Add(employeeNo);
|
||||
|
||||
var binding = await _accessDeviceRepo
|
||||
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
|
||||
if (binding != null)
|
||||
{
|
||||
binding.DeviceReportedName = person.Name;
|
||||
binding.FingerprintCount = Math.Max(0, person.FingerprintCount);
|
||||
binding.ActualAccessState =
|
||||
IsBlocked(person.UserType) ? "Disabled" : "Enabled";
|
||||
var matchesDesired =
|
||||
binding.ActualAccessState == binding.AccessProfile.DesiredAccessState;
|
||||
binding.ProvisionStatus = matchesDesired ? "Synced" : "Pending";
|
||||
binding.LastError = matchesDesired
|
||||
? null
|
||||
: "Terminal access state differs from the platform; repair queued.";
|
||||
binding.LastSyncedAt = now;
|
||||
binding.UpdatedAt = now;
|
||||
if (!matchesDesired)
|
||||
await QueueInventoryRepairAsync(binding, "SetAccessState", now);
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var unlinked = await _accessDeviceRepo
|
||||
.GetUnlinkedEnrollmentByEmployeeNoAsync(
|
||||
request.DeviceId, employeeNo);
|
||||
|
||||
if (unlinked == null)
|
||||
{
|
||||
unlinked = new UnlinkedDeviceEnrollment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKDeviceId = request.DeviceId,
|
||||
DeviceEmployeeNo = employeeNo,
|
||||
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
||||
DiscoveredAt = now
|
||||
};
|
||||
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(unlinked);
|
||||
discovered++;
|
||||
}
|
||||
|
||||
unlinked.DeviceReportedName = person.Name;
|
||||
unlinked.DeviceUserType = person.UserType;
|
||||
unlinked.FingerprintCount = Math.Max(0, person.FingerprintCount);
|
||||
unlinked.LastSeenAt = now;
|
||||
}
|
||||
|
||||
var missingBindings = await _accessDeviceRepo
|
||||
.GetBindingsForDeviceAsync(request.DeviceId);
|
||||
foreach (var binding in missingBindings.Where(
|
||||
b => !reportedEmployeeNos.Contains(b.DeviceEmployeeNo)))
|
||||
{
|
||||
binding.ProvisionStatus = "Pending";
|
||||
binding.ActualAccessState = "Unknown";
|
||||
binding.FingerprintCount = 0;
|
||||
binding.LastError = "Terminal person is missing; recreation queued.";
|
||||
binding.LastSyncedAt = now;
|
||||
binding.UpdatedAt = now;
|
||||
await QueueInventoryRepairAsync(binding, "UpsertPerson", now);
|
||||
}
|
||||
|
||||
await _UoW.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var total = request.People?.Count ?? 0;
|
||||
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
|
||||
{
|
||||
deviceId = request.DeviceId,
|
||||
discovered,
|
||||
updated,
|
||||
total,
|
||||
occurredAt = now
|
||||
});
|
||||
return new { discovered, updated, total };
|
||||
}
|
||||
|
||||
public async Task<object> IngestAttendanceAsync(
|
||||
Guid clientId, Guid agentId, AgentAttendanceRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
throw new BadRequestException("Request body is required.");
|
||||
var device = await _accessDeviceRepo
|
||||
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
|
||||
if (device == null)
|
||||
throw new ForbiddenException("Device does not belong to this client.");
|
||||
if (device.FKAgentId != agentId)
|
||||
throw new ForbiddenException("Device is assigned to another agent.");
|
||||
|
||||
var eventCount = request.Events?.Count ?? 0;
|
||||
var attendanceList = new List<AttendanceDTO>();
|
||||
var unknown = 0;
|
||||
|
||||
foreach (var ev in request.Events ?? new List<AgentAttendanceEvent>())
|
||||
{
|
||||
var employeeNo = ev.EmployeeNoString?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(employeeNo))
|
||||
continue;
|
||||
|
||||
var binding = await _accessDeviceRepo
|
||||
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
|
||||
if (binding == null)
|
||||
{
|
||||
await UpsertUnknownFromAttendanceAsync(request.DeviceId, employeeNo);
|
||||
unknown++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Guid? customerId = null;
|
||||
if (string.Equals(
|
||||
binding.AccessProfile.PersonType,
|
||||
"Customer",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
customerId = await _accessDeviceRepo
|
||||
.GetCustomerIdForUserAtClientAsync(
|
||||
binding.AccessProfile.FKUserId, clientId);
|
||||
}
|
||||
|
||||
attendanceList.Add(new AttendanceDTO
|
||||
{
|
||||
DeviceId = request.DeviceId,
|
||||
CustomerId = customerId,
|
||||
UserId = binding.AccessProfile.FKUserId,
|
||||
AccessProfileId = binding.FKAccessProfileId,
|
||||
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
||||
DeviceEmployeeNo = employeeNo,
|
||||
EventTime = ev.Time,
|
||||
EventType = string.IsNullOrWhiteSpace(ev.AttendanceStatus) ? "Unknown" : ev.AttendanceStatus
|
||||
});
|
||||
}
|
||||
|
||||
device.LastSeenAt = DateTime.UtcNow;
|
||||
if (unknown > 0 || eventCount > 0)
|
||||
await _UoW.SaveChangesAsync();
|
||||
|
||||
var inserted = await _accessDeviceService.SaveAttendanceRecords(clientId, attendanceList);
|
||||
if (inserted > 0 || unknown > 0)
|
||||
{
|
||||
await PublishAccessEventAsync(clientId, "AttendanceUpdated", new
|
||||
{
|
||||
deviceId = request.DeviceId,
|
||||
inserted,
|
||||
unknown,
|
||||
received = eventCount,
|
||||
occurredAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
if (unknown > 0)
|
||||
{
|
||||
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
|
||||
{
|
||||
deviceId = request.DeviceId,
|
||||
discovered = unknown,
|
||||
updated = 0,
|
||||
total = unknown,
|
||||
occurredAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Attendance ingest: client {ClientId}, device {DeviceId}, {Received} received, " +
|
||||
"{Unknown} unknown, {Inserted} inserted.",
|
||||
clientId, request.DeviceId, eventCount, unknown, inserted);
|
||||
return new { inserted, unknown };
|
||||
}
|
||||
|
||||
public async Task<object> GetPendingCommandsAsync(Guid clientId, Guid agentId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Close commands whose final lease expired without a successful report.
|
||||
var exhausted = await _deviceCommandRepo
|
||||
.GetExhaustedLeasesAsync(clientId, now);
|
||||
foreach (var command in exhausted)
|
||||
{
|
||||
command.Status = "Failed";
|
||||
command.ProcessedAt = now;
|
||||
command.ResultJson = command.ResultJson ?? "{\"detail\":\"Command lease expired.\"}";
|
||||
if (command.AccessDeviceBinding != null)
|
||||
{
|
||||
command.AccessDeviceBinding.ProvisionStatus = "Failed";
|
||||
command.AccessDeviceBinding.LastError = "Command lease expired.";
|
||||
command.AccessDeviceBinding.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
var commands = await _deviceCommandRepo
|
||||
.GetLeaseableCommandsAsync(clientId, agentId, now, 100);
|
||||
|
||||
foreach (var command in commands)
|
||||
{
|
||||
command.Status = "InProgress";
|
||||
command.AttemptCount++;
|
||||
command.LeaseExpiresAt = now.AddSeconds(90);
|
||||
}
|
||||
await _UoW.SaveChangesAsync();
|
||||
|
||||
return commands.Select(c => new
|
||||
{
|
||||
id = c.Id,
|
||||
commandType = c.CommandType,
|
||||
payloadJson = c.PayloadJson,
|
||||
fkDeviceId = c.FKDeviceId,
|
||||
attempt = c.AttemptCount,
|
||||
createdAt = c.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> SubmitCommandResultAsync(
|
||||
Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
throw new BadRequestException("Request body is required.");
|
||||
|
||||
var command = await _deviceCommandRepo.GetForAgentResultAsync(commandId)
|
||||
?? throw new NotFoundException($"Command {commandId} not found.");
|
||||
if (command.FKClientId != clientId)
|
||||
throw new ForbiddenException("Command does not belong to this client.");
|
||||
if (command.AccessDevice?.FKAgentId != agentId)
|
||||
throw new ForbiddenException("Command belongs to another agent.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var binding = command.AccessDeviceBinding;
|
||||
var versionIsCurrent = binding == null ||
|
||||
command.DesiredVersion == binding.AccessProfile.DesiredVersion;
|
||||
|
||||
// A face photo carries its image in the payload and reports enrolment state on
|
||||
// the profile, not the binding's access state -- so it takes its own path.
|
||||
var isFacePhoto = command.CommandType == "UpsertFacePhoto";
|
||||
|
||||
if (isFacePhoto)
|
||||
{
|
||||
HandleFacePhotoResult(command, binding, request, versionIsCurrent, now);
|
||||
}
|
||||
else if (request.Success)
|
||||
{
|
||||
command.Status = "Completed";
|
||||
command.ProcessedAt = now;
|
||||
command.LeaseExpiresAt = null;
|
||||
if (binding != null && versionIsCurrent)
|
||||
{
|
||||
binding.ProvisionStatus = "Synced";
|
||||
binding.ActualAccessState = ReadEnabled(command.PayloadJson) ? "Enabled" : "Disabled";
|
||||
binding.LastError = null;
|
||||
binding.LastSyncedAt = now;
|
||||
binding.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
else if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
|
||||
{
|
||||
command.Status = "Pending";
|
||||
command.LeaseExpiresAt = null;
|
||||
command.NextAttemptAt = now.AddSeconds(Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
|
||||
if (binding != null)
|
||||
{
|
||||
binding.ProvisionStatus = "Pending";
|
||||
binding.LastError = ExtractResultDetail(request.ResultJson);
|
||||
binding.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
command.Status = "Failed";
|
||||
command.ProcessedAt = now;
|
||||
command.LeaseExpiresAt = null;
|
||||
if (binding != null && versionIsCurrent)
|
||||
{
|
||||
binding.ProvisionStatus = "Failed";
|
||||
binding.LastError = ExtractResultDetail(request.ResultJson);
|
||||
binding.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
command.ResultJson = request.ResultJson;
|
||||
await _UoW.SaveChangesAsync();
|
||||
|
||||
if (isFacePhoto)
|
||||
{
|
||||
var profile = binding?.AccessProfile;
|
||||
await PublishAccessEventAsync(clientId, "FacePhotoUpdated", new
|
||||
{
|
||||
commandId = command.Id,
|
||||
bindingId = command.FKAccessDeviceBindingId,
|
||||
accessProfileId = profile?.AccessProfileId,
|
||||
facePhotoStatus = profile?.FacePhotoStatus,
|
||||
hasFacePhoto = profile?.HasFacePhoto,
|
||||
error = profile?.FacePhotoError,
|
||||
occurredAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await PublishAccessEventAsync(clientId, "AccessStateUpdated", new
|
||||
{
|
||||
commandId = command.Id,
|
||||
bindingId = command.FKAccessDeviceBindingId,
|
||||
status = command.Status,
|
||||
success = request.Success,
|
||||
occurredAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return new { command.Id, command.Status, command.AttemptCount, command.MaxAttempts };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a face-photo command result to the access profile and, on any terminal
|
||||
/// state, drops the image from the command payload. The photo is only ever held
|
||||
/// while it is in flight to a terminal -- never stored.
|
||||
/// </summary>
|
||||
private void HandleFacePhotoResult(
|
||||
DeviceCommand command,
|
||||
AccessDeviceBinding? binding,
|
||||
CommandResultRequest request,
|
||||
bool versionIsCurrent,
|
||||
DateTime now)
|
||||
{
|
||||
var profile = binding?.AccessProfile;
|
||||
|
||||
if (request.Success)
|
||||
{
|
||||
command.Status = "Completed";
|
||||
command.ProcessedAt = now;
|
||||
command.LeaseExpiresAt = null;
|
||||
command.PayloadJson = string.Empty; // purge the image
|
||||
|
||||
if (profile != null && versionIsCurrent)
|
||||
{
|
||||
profile.HasFacePhoto = true;
|
||||
profile.FacePhotoStatus = "Synced";
|
||||
profile.FacePhotoError = null;
|
||||
// The agent enrols the face under the person's employeeNo, so that is the
|
||||
// FPID. It is read from the result when the agent reports one, and falls
|
||||
// back to the binding otherwise -- either way this must be populated, as
|
||||
// it is the only handle a later replace/delete has.
|
||||
profile.FacePhotoFPID =
|
||||
ExtractFacePhotoFpid(request.ResultJson) ?? binding?.DeviceEmployeeNo;
|
||||
profile.FacePhotoUpdatedAt = now;
|
||||
profile.UpdatedAt = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A rejected face (not frontal, eyes closed, poor lighting) will be rejected the
|
||||
// same way on every retry, so the payload is kept only while retries remain.
|
||||
if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
|
||||
{
|
||||
command.Status = "Pending";
|
||||
command.LeaseExpiresAt = null;
|
||||
command.NextAttemptAt = now.AddSeconds(
|
||||
Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
profile.FacePhotoStatus = "Pending";
|
||||
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
|
||||
profile.FacePhotoUpdatedAt = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
command.Status = "Failed";
|
||||
command.ProcessedAt = now;
|
||||
command.LeaseExpiresAt = null;
|
||||
command.PayloadJson = string.Empty; // purge the image
|
||||
|
||||
if (profile != null && versionIsCurrent)
|
||||
{
|
||||
profile.FacePhotoStatus = "Failed";
|
||||
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
|
||||
profile.FacePhotoUpdatedAt = now;
|
||||
profile.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pulls the Hikvision face-record id out of the agent's result JSON.</summary>
|
||||
private static string? ExtractFacePhotoFpid(string? resultJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultJson))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
foreach (var name in new[] { "fpid", "FPID", "faceId", "faceURL" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(name, out var value) &&
|
||||
value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var id = value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
return id.Length <= 64 ? id : id[..64];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<object> HeartbeatAsync(
|
||||
Guid clientId, Guid agentId, AgentHeartbeatRequest request)
|
||||
{
|
||||
var serverTime = DateTime.UtcNow;
|
||||
await _agentRepo.UpdateLastSeenAsync(agentId, serverTime);
|
||||
|
||||
var reports = (request.Devices ?? new List<AgentDeviceHealthReport>())
|
||||
.Where(report => report.DeviceId != Guid.Empty)
|
||||
.GroupBy(report => report.DeviceId)
|
||||
.Select(group => group.Last())
|
||||
.ToList();
|
||||
var reportedIds = reports.Select(report => report.DeviceId).ToList();
|
||||
var devices = reportedIds.Count == 0
|
||||
? new List<AccessDevice>()
|
||||
: await _accessDeviceRepo.GetAgentDevicesAsync(
|
||||
clientId, agentId, reportedIds);
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var report = reports.First(item => item.DeviceId == device.DeviceId);
|
||||
device.IsReachable = report.IsOnline;
|
||||
device.LastHealthReportedAt = serverTime;
|
||||
if (report.IsOnline)
|
||||
device.LastSeenAt = serverTime;
|
||||
}
|
||||
await _UoW.SaveChangesAsync();
|
||||
|
||||
var presence = devices.Select(device => new
|
||||
{
|
||||
deviceId = device.DeviceId,
|
||||
isOnline = device.IsActive && device.IsReachable,
|
||||
lastSeenAt = device.LastSeenAt,
|
||||
presenceExpiresAt = device.IsActive && device.IsReachable
|
||||
? serverTime.Add(DevicePresenceWindow)
|
||||
: (DateTime?)null
|
||||
}).ToList();
|
||||
if (presence.Count > 0)
|
||||
{
|
||||
await PublishAccessEventAsync(clientId, "DevicePresenceUpdated", new
|
||||
{
|
||||
agentId,
|
||||
devices = presence,
|
||||
occurredAt = serverTime
|
||||
});
|
||||
}
|
||||
|
||||
return new { serverTime, devices = presence };
|
||||
}
|
||||
|
||||
private async Task UpsertUnknownFromAttendanceAsync(Guid deviceId, string employeeNo)
|
||||
{
|
||||
var existing = await _accessDeviceRepo
|
||||
.GetUnlinkedEnrollmentByEmployeeNoAsync(deviceId, employeeNo);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.LastSeenAt = DateTime.UtcNow;
|
||||
return;
|
||||
}
|
||||
|
||||
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(new UnlinkedDeviceEnrollment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKDeviceId = deviceId,
|
||||
DeviceEmployeeNo = employeeNo,
|
||||
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
|
||||
DiscoveredAt = DateTime.UtcNow,
|
||||
LastSeenAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private async Task QueueInventoryRepairAsync(
|
||||
AccessDeviceBinding binding, string commandType, DateTime now)
|
||||
{
|
||||
var profile = binding.AccessProfile;
|
||||
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
|
||||
binding.AccessDeviceBindingId,
|
||||
commandType,
|
||||
profile.DesiredVersion);
|
||||
if (alreadyQueued)
|
||||
return;
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
bindingId = binding.AccessDeviceBindingId,
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
employeeNo = binding.DeviceEmployeeNo,
|
||||
name = profile.DisplayName,
|
||||
enabled = profile.DesiredAccessState == "Enabled",
|
||||
desiredVersion = profile.DesiredVersion
|
||||
});
|
||||
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = profile.FKClientId,
|
||||
FKDeviceId = binding.FKDeviceId,
|
||||
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
||||
CommandType = commandType,
|
||||
PayloadJson = payloadJson,
|
||||
DesiredVersion = profile.DesiredVersion,
|
||||
Status = "Pending",
|
||||
MaxAttempts = 5,
|
||||
NextAttemptAt = now,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBlocked(string? userType) =>
|
||||
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool ReadEnabled(string payloadJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
return !doc.RootElement.TryGetProperty("enabled", out var enabled) || enabled.GetBoolean();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractResultDetail(string? resultJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultJson))
|
||||
return "Device command failed.";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
if (doc.RootElement.TryGetProperty("detail", out var detail))
|
||||
return detail.GetString() ?? resultJson;
|
||||
}
|
||||
catch { }
|
||||
return resultJson.Length <= 1000 ? resultJson : resultJson[..1000];
|
||||
}
|
||||
|
||||
private async Task PublishAccessEventAsync(
|
||||
Guid clientId, string eventName, object payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _accessHub.Clients
|
||||
.Group(AccessHub.ClientGroup(clientId))
|
||||
.SendAsync(eventName, payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Device ingestion is authoritative; a transient socket/backplane
|
||||
// failure must never make the agent retry a committed database write.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Could not publish {EventName} for client {ClientId}.",
|
||||
eventName,
|
||||
clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class AttendanceService : IAttendanceService
|
||||
{
|
||||
private readonly IAttendanceRepository _attendanceRepo;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IAccessDeviceService _accessDeviceService;
|
||||
|
||||
public AttendanceService(
|
||||
IAttendanceRepository attendanceRepo,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IAccessDeviceService accessDeviceService)
|
||||
{
|
||||
_attendanceRepo = attendanceRepo;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_accessDeviceService = accessDeviceService;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllAttendanceRecords(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
//await _accessDeviceService.GetAttendance(clientId);
|
||||
return await _attendanceRepo.GetLatestPerPersonAsync(clientId);
|
||||
}
|
||||
|
||||
public async Task<object> GetAttendanceCounts(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
//await _accessDeviceService.GetAttendance(clientId);
|
||||
var fromDate = DataExtractor.GetOptionalDateTime(data, "fromDate");
|
||||
var toDate = DataExtractor.GetOptionalDateTime(data, "toDate");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
DateTime resolvedFrom;
|
||||
DateTime resolvedTo;
|
||||
|
||||
if (fromDate == null && toDate == null)
|
||||
{
|
||||
resolvedFrom = new DateTime(now.Year, now.Month, 1);
|
||||
resolvedTo = now.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
else if (fromDate != null && toDate == null)
|
||||
{
|
||||
resolvedFrom = fromDate.Value;
|
||||
resolvedTo = now.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedFrom = fromDate!.Value;
|
||||
resolvedTo = toDate!.Value.Date.AddDays(1).AddTicks(-1);
|
||||
}
|
||||
|
||||
return await _attendanceRepo.GetAttendanceCountsAsync(clientId, resolvedFrom, resolvedTo);
|
||||
}
|
||||
|
||||
public async Task<object> GetTodayAttendance(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
//await _accessDeviceService.GetAttendance(clientId);
|
||||
return await _attendanceRepo.GetTodayAttendanceAsync(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
using Fitness_core_BE.DTOs.Auth;
|
||||
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 System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services.Auth
|
||||
{
|
||||
public class AuthService(IHttpClientFactory httpClientFactory, ICustomerRepository customerRepository, IUnitOfWork unitOfWork, IConfiguration configuration) : IAuthService
|
||||
{
|
||||
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient();
|
||||
private readonly ICustomerRepository _cusRepo = customerRepository;
|
||||
private readonly IUnitOfWork _UoW = unitOfWork;
|
||||
private readonly string _baseUrl = configuration["AuthService:BaseUrl"] ?? "http://127.0.0.1:5602";
|
||||
|
||||
private string AuthAltTargetUrl => _baseUrl + "/api/alt";
|
||||
private string AuthUserTargetUrl => _baseUrl + "/api/user";
|
||||
private string AuthRecoveryTargetUrl => _baseUrl + "/api/recovery";
|
||||
|
||||
|
||||
|
||||
public async Task<AuthResponse> SendOtp(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthAltTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "sendOtp",
|
||||
payload,
|
||||
reference = "send-otp"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> LoginUser(object payload, HttpContext httpContext)
|
||||
{
|
||||
// Web clients explicitly opt in to a persistent 30-day cookie. Without
|
||||
// this, closing the browser ends the session even though Auth retains the
|
||||
// server-side refresh session for security/audit purposes.
|
||||
var loginData = DeserializePayload.Deserialize(payload);
|
||||
var rememberMe = loginData?.TryGetValue("rememberMe", out var rememberValue) == true
|
||||
&& rememberValue.ValueKind is System.Text.Json.JsonValueKind.True;
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
|
||||
var request = new
|
||||
{
|
||||
functionName = "loginUser",
|
||||
payload,
|
||||
reference = "LoginUser"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>( responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (authResponse == null)
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
|
||||
if (authResponse.Success && authResponse.Data is JsonElement dataElement)
|
||||
{
|
||||
SetAuthCookiesFromData(httpContext, dataElement, rememberMe);
|
||||
}
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Auth service error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets the access_token (and refresh_token if present) cookies from an auth-service data payload.</summary>
|
||||
private void SetAuthCookiesFromData(HttpContext httpContext, JsonElement dataElement, bool persistent = true)
|
||||
{
|
||||
string? accessToken = null;
|
||||
if (dataElement.TryGetProperty("accessToken", out var tokenProp) ||
|
||||
dataElement.TryGetProperty("AccessToken", out tokenProp))
|
||||
accessToken = tokenProp.GetString();
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
SetAccessTokenCookieInternal(httpContext, accessToken, persistent);
|
||||
|
||||
string? refreshToken = null;
|
||||
if (dataElement.TryGetProperty("refreshToken", out var refreshProp) ||
|
||||
dataElement.TryGetProperty("RefreshToken", out refreshProp))
|
||||
refreshToken = refreshProp.GetString();
|
||||
if (!string.IsNullOrEmpty(refreshToken))
|
||||
SetRefreshTokenCookie(httpContext, refreshToken, persistent);
|
||||
}
|
||||
|
||||
public void SetAccessTokenCookie(HttpContext httpContext, string token)
|
||||
=> SetAccessTokenCookieInternal(httpContext, token, persistent: true);
|
||||
|
||||
private void SetAccessTokenCookieInternal(HttpContext httpContext, string token, bool persistent)
|
||||
{
|
||||
var requestHost = httpContext.Request.Host.Host;
|
||||
var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
|
||||
var isHttpsRequest =
|
||||
httpContext.Request.IsHttps ||
|
||||
string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
httpContext.Response.Cookies.Append("access_token", token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = isHttpsRequest,
|
||||
SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax,
|
||||
Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null,
|
||||
Path = "/",
|
||||
Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com"
|
||||
});
|
||||
}
|
||||
|
||||
public void SetRefreshTokenCookie(HttpContext httpContext, string refreshToken, bool persistent = true)
|
||||
{
|
||||
var requestHost = httpContext.Request.Host.Host;
|
||||
var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
|
||||
var isHttpsRequest =
|
||||
httpContext.Request.IsHttps ||
|
||||
string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
httpContext.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = isHttpsRequest,
|
||||
SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax,
|
||||
Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null,
|
||||
Path = "/",
|
||||
Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com"
|
||||
});
|
||||
}
|
||||
|
||||
public void ClearAuthCookies(HttpContext httpContext)
|
||||
{
|
||||
var requestHost = httpContext.Request.Host.Host;
|
||||
var domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com";
|
||||
var options = new CookieOptions { Path = "/", Domain = domain };
|
||||
httpContext.Response.Cookies.Delete("access_token", options);
|
||||
httpContext.Response.Cookies.Delete("refresh_token", options);
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> RefreshToken(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
|
||||
// The refresh token lives in an httpOnly cookie so the browser can trigger a
|
||||
// silent refresh without ever holding it in JS. Fall back to the request payload
|
||||
// (e.g. mobile clients that pass it explicitly).
|
||||
string? refreshToken = null;
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (data.TryGetValue("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String)
|
||||
refreshToken = rt.GetString();
|
||||
}
|
||||
catch { /* payload may be empty on cookie-driven refresh */ }
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken))
|
||||
refreshToken = httpContext.Request.Cookies["refresh_token"];
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
ClearAuthCookies(httpContext);
|
||||
return new AuthResponse { StatusCode = 401, Success = false, Message = "Missing refresh token", Data = null };
|
||||
}
|
||||
|
||||
var deviceName = httpContext.Request.Headers["User-Agent"].ToString();
|
||||
var request = new
|
||||
{
|
||||
functionName = "refreshToken",
|
||||
payload = new { refreshToken, deviceName },
|
||||
reference = "RefreshToken"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceName))
|
||||
forwardRequest.Headers.TryAddWithoutValidation("User-Agent", deviceName);
|
||||
|
||||
var forwardedFor = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(forwardedFor))
|
||||
forwardRequest.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (authResponse == null)
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
|
||||
if (authResponse.Success && authResponse.Data is JsonElement dataElement)
|
||||
{
|
||||
// Core-Hex-Auth rotates the refresh token; persist both new cookies.
|
||||
SetAuthCookiesFromData(httpContext, dataElement);
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
// Refresh token invalid/expired — clear cookies and report 401 so the
|
||||
// frontend redirects to login rather than surfacing a 500.
|
||||
ClearAuthCookies(httpContext);
|
||||
return new AuthResponse { StatusCode = 401, Success = false, Message = "Session expired. Please log in again.", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//customer ta only use? why
|
||||
public async Task<AuthResponse> VerifyOTPForCus(object payload, HttpContext httpContext)
|
||||
{
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
|
||||
|
||||
var targetUrl = AuthRecoveryTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "verifyOTP",
|
||||
payload,
|
||||
reference = "verify-otp"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (authResponse == null)
|
||||
throw new Exception("Empty response from auth service");
|
||||
|
||||
string identifireType = DataExtractor.GetRequiredString(data, "identifireType");
|
||||
var identifier = DataExtractor.GetRequiredString(data, "identifier");
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
bool result = await _cusRepo.VerifyCustomer(userId, identifireType, identifier);
|
||||
if (!result)
|
||||
throw new Exception("verfifying faled");
|
||||
await _UoW.CommitAsync();
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw new Exception($"{ex.Message}");
|
||||
}
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<AuthResponse> VerifyOtpForLogin(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "VerifyOtpForLogin",
|
||||
payload,
|
||||
reference = "verify-otp-for-login"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<AuthResponse> IsAvailable(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthAltTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "IsAvailable",
|
||||
payload,
|
||||
reference = "is-available"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<AuthResponse> RegisterUser(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "registerUser",
|
||||
payload,
|
||||
reference = "register-user"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<AuthResponse> GetUserDetails(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "getUserDetails",
|
||||
payload,
|
||||
reference = "user-details"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
forwardRequest.Headers.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
authHeader.Replace("Bearer ", "")
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<AuthResponse> UpdateUser(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "UpdateUser",
|
||||
payload,
|
||||
reference = "user-details"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
// Web clients authenticate via the access_token cookie, not an Authorization
|
||||
// header — forward whichever is present so the auth service can read the userId.
|
||||
ApplyAuthHeader(forwardRequest, httpContext);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> AdminUpdateUser(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "adminUpdateUser",
|
||||
payload,
|
||||
reference = "admin-update-user"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards a recovery-manager function (forgotPassword / resetPassword) — no auth required.</summary>
|
||||
private async Task<AuthResponse> ForwardToRecovery(string functionName, object payload)
|
||||
{
|
||||
var request = new { functionName, payload, reference = functionName };
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthRecoveryTargetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AuthResponse> ForgotPassword(object payload, HttpContext httpContext)
|
||||
=> ForwardToRecovery("forgotPassword", payload);
|
||||
|
||||
public Task<AuthResponse> ResetPassword(object payload, HttpContext httpContext)
|
||||
=> ForwardToRecovery("resetPassword", payload);
|
||||
|
||||
public Task<AuthResponse> ResetPasswordWithToken(object payload, HttpContext httpContext)
|
||||
=> ForwardToRecovery("resetPasswordWithToken", payload);
|
||||
|
||||
// Confirms the caller's own password (used to unlock sensitive views). Returns { valid }.
|
||||
public async Task<AuthResponse> VerifyPassword(object payload, HttpContext httpContext)
|
||||
{
|
||||
var request = new { functionName = "VerifyPassword", payload, reference = "verify-password" };
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthUserTargetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
ApplyAuthHeader(forwardRequest, httpContext);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets the outgoing Bearer token from the Authorization header, the MobileAuthToken header, or the access_token cookie.</summary>
|
||||
private static void ApplyAuthHeader(HttpRequestMessage req, HttpContext ctx)
|
||||
{
|
||||
string? token = null;
|
||||
|
||||
var authHeader = ctx.Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader))
|
||||
token = authHeader.Replace("Bearer ", "").Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var mobile = ctx.Request.Headers["MobileAuthToken"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(mobile) && mobile.StartsWith("Bearer "))
|
||||
token = mobile.Substring("Bearer ".Length).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
token = ctx.Request.Cookies["access_token"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// new otp verfy for client
|
||||
public async Task<AuthResponse> VerifyOTP(object payload, HttpContext httpContext)
|
||||
{
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
|
||||
|
||||
var targetUrl = AuthRecoveryTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "verifyOTP",
|
||||
payload,
|
||||
reference = "verify-otp"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (authResponse == null)
|
||||
throw new Exception("Empty response from auth service");
|
||||
|
||||
return authResponse;
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
//Logout user by clearing cookie
|
||||
public async Task<AuthResponse> LogOut(object payload, HttpContext httpContext)
|
||||
{
|
||||
var targetUrl = AuthUserTargetUrl;
|
||||
var request = new
|
||||
{
|
||||
functionName = "LogoutUser",
|
||||
payload,
|
||||
reference = "log-out"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
|
||||
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
// Always clear cookies locally, even if the remote revoke fails — the user
|
||||
// clicked logout and their browser session must end.
|
||||
ClearAuthCookies(httpContext);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(forwardRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return authResponse ?? new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null };
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLocalHost(string? host)
|
||||
{
|
||||
return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using Fitness_core_BE.DTOs.Booking;
|
||||
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 System.Security.Cryptography;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class BookingService : IBookingService
|
||||
{
|
||||
private readonly IBookingRepository _bookingRepository;
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public BookingService(
|
||||
IBookingRepository bookingRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_bookingRepository = bookingRepository;
|
||||
_facilityRepository = facilityRepository;
|
||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
|
||||
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
if (session.IsCancelled)
|
||||
throw new ArgumentException("This session has been cancelled");
|
||||
if (session.StartTime <= DateTime.UtcNow)
|
||||
throw new ArgumentException("This session has already started");
|
||||
|
||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId))
|
||||
throw new ArgumentException("You already have a booking for this session");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId)
|
||||
?? throw new ArgumentException("No active membership plan found");
|
||||
|
||||
// Null SessionsPerCycle = unlimited-access plan (existing gym behavior) --
|
||||
// no allowance to check. A set value caps how many Facility sessions this
|
||||
// cycle's plan covers; SessionsCarriedOver (from a GracePeriod rollover, set at
|
||||
// renewal approval -- see SubscriptionRenewalApprovalService.ApproveAsync)
|
||||
// extends that cap only until SessionsCarriedOverExpiresAt.
|
||||
if (subscription.CustomerPlan.SessionsPerCycle.HasValue)
|
||||
{
|
||||
var carriedOver = subscription.SessionsCarriedOverExpiresAt.HasValue
|
||||
&& subscription.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow
|
||||
? 0
|
||||
: subscription.SessionsCarriedOver;
|
||||
|
||||
var allowance = subscription.CustomerPlan.SessionsPerCycle.Value + carriedOver;
|
||||
if (subscription.SessionsUsedThisCycle >= allowance)
|
||||
throw new ArgumentException("You've used all the sessions included in your current plan cycle");
|
||||
}
|
||||
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = facilitySessionId,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = false,
|
||||
FKCustomerId = cusId,
|
||||
FKCustomerSubscriptionId = subscription.SubscriptionId,
|
||||
PaymentStatus = "NotRequired",
|
||||
Status = "Confirmed"
|
||||
};
|
||||
|
||||
await _bookingRepository.CreateBooking(booking);
|
||||
|
||||
if (subscription.CustomerPlan.SessionsPerCycle.HasValue)
|
||||
subscription.SessionsUsedThisCycle++;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateGuestBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||
|
||||
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
if (session.IsCancelled)
|
||||
throw new ArgumentException("This session has been cancelled");
|
||||
if (session.StartTime <= DateTime.UtcNow)
|
||||
throw new ArgumentException("This session has already started");
|
||||
|
||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||
if (bookedCount >= capacity)
|
||||
throw new ArgumentException("This session is fully booked");
|
||||
|
||||
var booking = new Booking
|
||||
{
|
||||
FKFacilitySessionId = facilitySessionId,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = true,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
// Payment happens separately -- online (not wired up yet) or at the venue,
|
||||
// recorded via MarkGuestBookingPaid. The booking + OTP receipt exist either way.
|
||||
PaymentStatus = "Unpaid",
|
||||
Status = "Confirmed"
|
||||
};
|
||||
|
||||
await _bookingRepository.CreateBooking(booking);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// The only time the OTP is ever returned -- this response IS the guest's receipt.
|
||||
return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
var amountCharged = data["amountCharged"].GetDecimal();
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (!booking.IsGuest)
|
||||
throw new ArgumentException("Only guest bookings take a manual payment record");
|
||||
|
||||
booking.PaymentStatus = "Paid";
|
||||
booking.AmountCharged = amountCharged;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CheckInBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required");
|
||||
|
||||
var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode)
|
||||
?? throw new KeyNotFoundException("No confirmed booking found for this code");
|
||||
|
||||
if (booking.OtpVerifiedAt.HasValue)
|
||||
throw new ArgumentException("This booking has already been checked in");
|
||||
|
||||
booking.OtpVerifiedAt = DateTime.UtcNow;
|
||||
booking.CheckedInAt = DateTime.UtcNow;
|
||||
booking.Status = "CheckedIn";
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||
?? throw new KeyNotFoundException("Booking not found");
|
||||
if (booking.Status == "Cancelled")
|
||||
throw new ArgumentException("This booking is already cancelled");
|
||||
if (booking.Status == "CheckedIn")
|
||||
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
|
||||
|
||||
booking.Status = "Cancelled";
|
||||
|
||||
// Give the session allowance back -- only member bookings draw one down.
|
||||
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
||||
{
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value);
|
||||
if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value
|
||||
&& subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
||||
{
|
||||
subscription.SessionsUsedThisCycle--;
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
|
||||
{
|
||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
|
||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
|
||||
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
|
||||
// Staff view -- never expose guest OTPs here either; the door check-in flow reads
|
||||
// the OTP the guest already has, it doesn't need staff to look it up beforehand.
|
||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
||||
}
|
||||
|
||||
public async Task<object?> GetBookingView(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||
|
||||
var booking = await _bookingRepository.GetBookingById(bookingId);
|
||||
if (booking == null)
|
||||
return null;
|
||||
|
||||
// The share link itself is the credential (an unguessable GUID) -- the OTP is only
|
||||
// withheld once it's already been used, same rule as every other read path.
|
||||
return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue);
|
||||
}
|
||||
|
||||
private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
|
||||
|
||||
// Built from the request itself (scheme/host), not a config value, so the link is
|
||||
// correct in every environment (localhost during dev, whatever domain in production)
|
||||
// without needing a separate "public base URL" setting kept in sync.
|
||||
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
|
||||
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
|
||||
|
||||
private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new()
|
||||
{
|
||||
BookingId = booking.BookingId,
|
||||
FacilitySessionId = booking.FKFacilitySessionId,
|
||||
FacilityName = session?.Facility?.Name,
|
||||
SessionStartTime = session?.StartTime,
|
||||
SessionEndTime = session?.EndTime,
|
||||
IsGuest = booking.IsGuest,
|
||||
CustomerId = booking.FKCustomerId,
|
||||
GuestName = booking.GuestName,
|
||||
GuestPhone = booking.GuestPhone,
|
||||
OtpCode = includeOtp ? booking.OtpCode : null,
|
||||
OtpVerified = booking.OtpVerifiedAt.HasValue,
|
||||
PaymentStatus = booking.PaymentStatus,
|
||||
AmountCharged = booking.AmountCharged,
|
||||
Status = booking.Status,
|
||||
BookedAt = booking.BookedAt,
|
||||
CheckedInAt = booking.CheckedInAt,
|
||||
ShareUrl = shareUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Models.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
// GYM's onboarding/activation flow (ClientController's "onboarding/requests" ->
|
||||
// "activation/{token}" endpoints) is a multi-gym SaaS signup wizard -- OTP checks, NIC
|
||||
// upload, a payment-method + platform-billing charge step -- built for a platform onboarding
|
||||
// many paying gym clients. Vijaya is a single, already-real club, not a SaaS signup; this
|
||||
// is the one-time "create the club" step instead, guarded so it can only ever run once
|
||||
// (before that first Client row exists) rather than being a repeatable registration path.
|
||||
public class BootstrapService : IBootstrapService
|
||||
{
|
||||
private readonly CentralDbContext _central;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BootstrapService(CentralDbContext central, IUnitOfWork uow)
|
||||
{
|
||||
_central = central;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<object> BootstrapClub(object payload)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
if (await _central.Client.AnyAsync())
|
||||
throw new BadRequestException("The club is already set up.");
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("userId")) throw new ArgumentException("userId is required");
|
||||
if (!data.ContainsKey("clientName")) throw new ArgumentException("clientName is required");
|
||||
|
||||
var userId = Guid.Parse(data["userId"].GetString()!);
|
||||
var clientName = data["clientName"].GetString()!;
|
||||
|
||||
// core-api keeps its own local mirror of user identity (see CustomerRepository/
|
||||
// StaffRepository doing the same on customer/staff creation) -- Client.FKUserId
|
||||
// has a real local FK to this table, not to auth-service's database, since the
|
||||
// two services no longer share a connection. Idempotent: harmless if a mirror
|
||||
// row already exists for this user id.
|
||||
if (!await _central.Users.AnyAsync(u => u.UserId == userId))
|
||||
{
|
||||
_central.Users.Add(new User
|
||||
{
|
||||
UserId = userId,
|
||||
Email = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null,
|
||||
PhoneNumber = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null,
|
||||
IsActive = true
|
||||
});
|
||||
}
|
||||
|
||||
var client = new Client
|
||||
{
|
||||
ClientId = Guid.NewGuid(),
|
||||
FKUserId = userId,
|
||||
ClientName = clientName,
|
||||
ClientPhone = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null,
|
||||
ClientEmail = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null
|
||||
};
|
||||
_central.Client.Add(client);
|
||||
|
||||
_central.ClientBusiness.Add(new ClientBusiness
|
||||
{
|
||||
Bid = Guid.NewGuid(),
|
||||
FKClientId = client.ClientId,
|
||||
BusinessType = BusinessType.institute,
|
||||
BusinessName = clientName,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { clientId = client.ClientId, clientName = client.ClientName };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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.Helpers;
|
||||
|
||||
namespace Fitness_core_BE.Services.Chat
|
||||
{
|
||||
public class ChatService : IChatService
|
||||
{
|
||||
private const int MAX_MESSAGE_LENGTH = 100000;
|
||||
|
||||
private readonly IChatRepo _repository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public ChatService(IChatRepo repository, IUnitOfWork uow, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_repository = repository;
|
||||
_uow = uow;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<object> GetOrCreateChatAsync(object payload)
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid currentUserId= DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid otherUserId = DataExtractor.GetRequiredGuid(data, "otherUserId");
|
||||
|
||||
if (currentUserId == otherUserId)
|
||||
throw new InvalidOperationException("Cannot create chat with yourself.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var existingChat =
|
||||
await _repository.GetChatBetweenUsersAsync(currentUserId, otherUserId);
|
||||
|
||||
if (existingChat != null && existingChat.IsAcitve)
|
||||
{
|
||||
await _uow.CommitAsync();
|
||||
return existingChat;
|
||||
}
|
||||
|
||||
var chat = new Models.Chat
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKSenderId = currentUserId,
|
||||
FKReceiverId = otherUserId
|
||||
};
|
||||
|
||||
await _repository.CreateChatAsync(chat);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return chat;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SendMessageAsync(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId");
|
||||
Guid senderId = DataExtractor.GetRequiredGuid(data, "senderId");
|
||||
string message = DataExtractor.GetRequiredString(data, "message");
|
||||
string type = DataExtractor.GetRequiredString(data, "type");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ArgumentException("Message cannot be empty.");
|
||||
|
||||
message = message.Trim();
|
||||
|
||||
if (message.Length > MAX_MESSAGE_LENGTH)
|
||||
throw new ArgumentException($"Message exceeds {MAX_MESSAGE_LENGTH} characters.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var chat = await _repository.GetChatByIdAsync(chatId);
|
||||
|
||||
if (chat == null || !chat.IsAcitve)
|
||||
throw new KeyNotFoundException("Chat not found or inactive.");
|
||||
|
||||
if (senderId != chat.FKSenderId && senderId != chat.FKReceiverId)
|
||||
throw new UnauthorizedAccessException("User not part of this chat.");
|
||||
|
||||
var chatMessage = new ChatMessages
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKChatId = chatId,
|
||||
FKSenderId = senderId,
|
||||
Message = message,
|
||||
Type = type,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsAcitve = true
|
||||
};
|
||||
|
||||
await _repository.AddMessageAsync(chatMessage);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return chatMessage;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetMessagesAsync(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId");
|
||||
Guid userId = DataExtractor.GetRequiredGuid(data, "userId");
|
||||
int skip = DataExtractor.GetRequiredInt(data, "skip");
|
||||
int take = DataExtractor.GetRequiredInt(data, "take");
|
||||
if (take <= 0 || take > 100)
|
||||
take = 30;
|
||||
|
||||
if (!await CanUserAccessChatAsync(chatId, userId))
|
||||
throw new UnauthorizedAccessException("User not allowed to view this chat.");
|
||||
|
||||
return await _repository.GetMessagesAsync(chatId, skip, take);
|
||||
}
|
||||
|
||||
public async Task<bool> CanUserAccessChatAsync(Guid chatId, Guid userId)
|
||||
{
|
||||
return await _repository.IsUserInChatAsync(chatId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services.ClientInvoice
|
||||
{
|
||||
public class ClientInvoiceService : IClientInvoiceService
|
||||
{
|
||||
private readonly IClientInvoiceRepository _clientInvoiceRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ClientInvoiceService(IClientInvoiceRepository clientInvoiceRepository, IUnitOfWork uow)
|
||||
{
|
||||
_clientInvoiceRepository = clientInvoiceRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<object> CreateClientInvoiceFromSubscription(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("subscriptionId"))
|
||||
{
|
||||
throw new KeyNotFoundException("subscriptionId is required");
|
||||
}
|
||||
|
||||
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
|
||||
var result = await _clientInvoiceRepository.CreateClientInvoiceFromSubscription(subscriptionId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetClientInvoiceById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("clientInvoiceId"))
|
||||
{
|
||||
throw new KeyNotFoundException("clientInvoiceId is required");
|
||||
}
|
||||
|
||||
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
|
||||
var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId);
|
||||
|
||||
if (clientInvoice == null)
|
||||
{
|
||||
throw new KeyNotFoundException("ClientInvoice not found");
|
||||
}
|
||||
|
||||
return clientInvoice;
|
||||
}
|
||||
|
||||
public async Task<object> GetClientInvoicesByClientId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("clientId"))
|
||||
{
|
||||
throw new KeyNotFoundException("clientId is required");
|
||||
}
|
||||
|
||||
var clientId = Guid.Parse(data["clientId"].GetString()!);
|
||||
return await _clientInvoiceRepository.GetClientInvoicesByClientId(clientId);
|
||||
}
|
||||
|
||||
public async Task<object> GetClientInvoicesByUserId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("userId"))
|
||||
{
|
||||
throw new KeyNotFoundException("userId is required");
|
||||
}
|
||||
|
||||
var userId = Guid.Parse(data["userId"].GetString()!);
|
||||
return await _clientInvoiceRepository.GetClientInvoicesByUserId(userId);
|
||||
}
|
||||
|
||||
public async Task<object> UpdateClientInvoiceStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("clientInvoiceId") || !data.ContainsKey("status"))
|
||||
{
|
||||
throw new KeyNotFoundException("clientInvoiceId and status are required");
|
||||
}
|
||||
|
||||
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
|
||||
var status = data["status"].GetString()!;
|
||||
|
||||
// Validate status
|
||||
var validStatuses = new[] { "Pending", "Paid", "Failed", "Cancelled", "Refunded" };
|
||||
if (!validStatuses.Contains(status))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}");
|
||||
}
|
||||
|
||||
var result = await _clientInvoiceRepository.UpdateClientInvoiceStatus(clientInvoiceId, status);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CalculateClientInvoiceTotal(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("clientInvoiceId"))
|
||||
{
|
||||
throw new KeyNotFoundException("clientInvoiceId is required");
|
||||
}
|
||||
|
||||
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
|
||||
var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId);
|
||||
var total = clientInvoice?.Amount ?? 0;
|
||||
|
||||
return new { Total = total };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Fitness_core_BE.DTOs.PaymentGateways;
|
||||
using Fitness_core_BE.DTOs.PlatformBilling;
|
||||
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 Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
// The gym owner's own card(s) for paying their HexaFitness platform subscription.
|
||||
// Payment-Service is the source of truth for everything about a card (masked PAN, brand,
|
||||
// expiry, default flag) — this service never caches those locally. The one thing Core
|
||||
// keeps is ClientCard.CardToken, because ClientInvoice.FKClientCardId is a pre-existing
|
||||
// int FK that needs a stable local row to point at; a ClientCard row is only ever created
|
||||
// once Payment-Service has confirmed a card (see CompleteCardOnboardingAsync) — there's no
|
||||
// local "pending" bookkeeping, since Payment-Service already tracks that itself.
|
||||
public class ClientPaymentMethodService : IClientPaymentMethodService
|
||||
{
|
||||
private const string GatewayName = "DirectPay";
|
||||
|
||||
private readonly IPlatformBillingRepository _platformBillingRepository;
|
||||
private readonly IPaymentsEnablementRepository _paymentsEnablementRepository;
|
||||
private readonly IPaymentGatewayClient _paymentGatewayClient;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ClientPaymentMethodService(
|
||||
IPlatformBillingRepository platformBillingRepository,
|
||||
IPaymentsEnablementRepository paymentsEnablementRepository,
|
||||
IPaymentGatewayClient paymentGatewayClient,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_platformBillingRepository = platformBillingRepository;
|
||||
_paymentsEnablementRepository = paymentsEnablementRepository;
|
||||
_paymentGatewayClient = paymentGatewayClient;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<StartCardOnboardingResult> StartCardOnboardingAsync(
|
||||
HttpContext httpContext,
|
||||
StartCardOnboardingRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var client = await _platformBillingRepository.GetClientByIdAsync(clientId, ct)
|
||||
?? throw new NotFoundException("Client not found.");
|
||||
|
||||
var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0;
|
||||
|
||||
var response = await _paymentGatewayClient.OnboardCardAsync(
|
||||
clientId,
|
||||
new OnboardCardRequest
|
||||
{
|
||||
GatewayName = GatewayName,
|
||||
CustomerId = clientId.ToString(),
|
||||
CardLabel = string.IsNullOrWhiteSpace(request.CardLabel) ? "Gym billing card" : request.CardLabel,
|
||||
CardholderName = client.ClientName ?? "Gym Owner",
|
||||
IsDefault = isFirstCard,
|
||||
ReturnUrl = request.ReturnUrl
|
||||
},
|
||||
ct);
|
||||
|
||||
if (response.Status == "Active")
|
||||
{
|
||||
// Synchronous gateway (no hosted redirect) — finalize immediately. DirectPay
|
||||
// never takes this path today (always hosted/pending); kept for gateway-agnosticism.
|
||||
await PersistConfirmedCardAsync(clientId, response.CardToken, isFirstCard, ct);
|
||||
}
|
||||
|
||||
return new StartCardOnboardingResult
|
||||
{
|
||||
RedirectUrl = response.RedirectUrl ?? string.Empty,
|
||||
SessionId = response.GatewayRef
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CardOnboardingStatusResult> CompleteCardOnboardingAsync(
|
||||
HttpContext httpContext,
|
||||
string sessionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
// No local lookup — Payment-Service already tracks the pending onboarding by
|
||||
// (gatewayName, sessionId) and verifies it belongs to this platform.
|
||||
var status = await _paymentGatewayClient.GetOnboardingStatusAsync(clientId, GatewayName, sessionId, ct);
|
||||
if (status.Status != "Active")
|
||||
{
|
||||
return new CardOnboardingStatusResult { IsComplete = false };
|
||||
}
|
||||
|
||||
var existingLocal = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, status.CardToken, ct);
|
||||
if (existingLocal is null)
|
||||
{
|
||||
var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0;
|
||||
await PersistConfirmedCardAsync(clientId, status.CardToken, isFirstCard, ct);
|
||||
}
|
||||
|
||||
return new CardOnboardingStatusResult
|
||||
{
|
||||
IsComplete = true,
|
||||
Card = new ClientCardDto
|
||||
{
|
||||
CardToken = status.CardToken,
|
||||
MaskedPan = status.MaskedPan,
|
||||
CardBrand = status.CardBrand,
|
||||
ExpiryMonth = status.ExpiryMonth,
|
||||
ExpiryYear = status.ExpiryYear,
|
||||
IsDefault = status.IsDefault
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<ClientCardDto>> ListCardsAsync(HttpContext httpContext, CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var cards = await _paymentGatewayClient.ListCardsAsync(clientId, clientId.ToString(), ct);
|
||||
|
||||
return cards
|
||||
.Select(card => new ClientCardDto
|
||||
{
|
||||
CardToken = card.CardToken,
|
||||
MaskedPan = card.MaskedPan,
|
||||
CardBrand = card.CardBrand,
|
||||
ExpiryMonth = card.ExpiryMonth,
|
||||
ExpiryYear = card.ExpiryYear,
|
||||
IsDefault = card.IsDefault
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task RemoveCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
await _paymentGatewayClient.DeactivateCardAsync(clientId, cardToken, ct);
|
||||
|
||||
// Best-effort local sync: only exists to keep ClientInvoice's FK anchor consistent
|
||||
// with Payment-Service, not treated as the source of truth.
|
||||
var local = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, cardToken, ct);
|
||||
if (local is not null)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
local.IsActive = false;
|
||||
local.IsPrimary = false;
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClientCardDto> SetDefaultCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var result = await _paymentGatewayClient.SetDefaultCardAsync(clientId, cardToken, clientId.ToString(), ct);
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var localCards = await _platformBillingRepository.GetClientCardsAsync(clientId, ct);
|
||||
foreach (var other in localCards)
|
||||
{
|
||||
other.IsPrimary = other.CardToken == cardToken;
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var details = await _paymentGatewayClient.GetCardDetailsAsync(clientId, cardToken, ct);
|
||||
return new ClientCardDto
|
||||
{
|
||||
CardToken = result.CardToken,
|
||||
MaskedPan = details.MaskedPan,
|
||||
CardBrand = details.CardBrand,
|
||||
ExpiryMonth = details.ExpiryMonth,
|
||||
ExpiryYear = details.ExpiryYear,
|
||||
IsDefault = result.IsDefault
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<TransactionHistoryResult> GetTransactionHistoryAsync(
|
||||
HttpContext httpContext,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var result = await _paymentGatewayClient.ListPaymentsAsync(clientId, cardToken: null, page, pageSize, ct);
|
||||
|
||||
return new TransactionHistoryResult
|
||||
{
|
||||
Total = result.Total,
|
||||
Page = result.Page,
|
||||
Items = result.Items
|
||||
.Select(item => new TransactionHistoryItemDto
|
||||
{
|
||||
PaymentId = item.PaymentId,
|
||||
AmountMinorUnits = item.AmountMinorUnits,
|
||||
Currency = item.Currency,
|
||||
Status = item.Status,
|
||||
CreatedAtUtc = item.CreatedAtUtc
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PersistConfirmedCardAsync(Guid clientId, string cardToken, bool isPrimary, CancellationToken ct)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _paymentsEnablementRepository.CreateClientCardAsync(
|
||||
new ClientCard
|
||||
{
|
||||
FK_ClientId = clientId,
|
||||
CardToken = cardToken,
|
||||
IsActive = true,
|
||||
IsPrimary = isPrimary
|
||||
},
|
||||
ct);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class ClientStatService : IClientStatService
|
||||
{
|
||||
// Muscle Beast Gym (gymos_musslbeast) -- the only gym that gets the "Today's Payments"
|
||||
// stat, per their explicit request. Not a general feature-flag system; if another gym
|
||||
// wants this later, promote it to a real per-client setting instead of adding more IDs here.
|
||||
private static readonly Guid MuscleBeastClientId = Guid.Parse("b2abf7b3-a9f0-11f1-945a-54e8d4bf1eca");
|
||||
|
||||
private readonly IClientStatRepository _clientStatRepository;
|
||||
private readonly IOverdueLockRepository _overdueLockRepository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public ClientStatService(
|
||||
IClientStatRepository clientStatRepository,
|
||||
IOverdueLockRepository overdueLockRepository,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_clientStatRepository = clientStatRepository;
|
||||
_overdueLockRepository = overdueLockRepository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetTotalCustomerCount(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var count = await _clientStatRepository.GetTotalCustomerCountAsync(clientId);
|
||||
return new { totalCustomerCount = count };
|
||||
}
|
||||
|
||||
public async Task<object> GetMonthlyCustomerCount(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var count = await _clientStatRepository.GetMonthlyCustomerCountAsync(clientId);
|
||||
return new { monthlyCustomerCount = count };
|
||||
}
|
||||
|
||||
public async Task<object> GetTotalWorkoutPlanCount(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var count = await _clientStatRepository.GetTotalWorkoutPlanCountAsync(clientId);
|
||||
return new { totalWorkoutPlanCount = count };
|
||||
}
|
||||
|
||||
public async Task<object> GetTotalMealPlanCount(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var count = await _clientStatRepository.GetTotalMealPlanCountAsync(clientId);
|
||||
return new { totalMealPlanCount = count };
|
||||
}
|
||||
public async Task<object> GetLastMonthCustomerCount(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var count = await _clientStatRepository.GetLastMonthCustomerCountAsync(clientId);
|
||||
return new { lastMonthCustomerCount = count };
|
||||
}
|
||||
|
||||
public async Task<object> GetLastFiveAttendances(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var records = await _clientStatRepository.GetLastFiveAttendancesAsync(clientId);
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<object> GetLastFivePayments(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var records = await _clientStatRepository.GetLastFivePaymentsAsync(clientId);
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<object> GetLastMonthRevenue(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var revenue = await _clientStatRepository.GetLastMonthRevenueAsync(clientId);
|
||||
return new { lastMonthRevenue = revenue };
|
||||
}
|
||||
|
||||
public async Task<object> GetMonthlyRevenueVariation(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var stats = await _clientStatRepository.GetMonthlyRevenueVariationAsync(clientId, from, to);
|
||||
|
||||
var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue;
|
||||
if (!isOwner)
|
||||
{
|
||||
// Revenue is owner-only, enforced here (not just hidden client-side) — staff
|
||||
// still get real NewMemberCount points, just no Revenue figures.
|
||||
foreach (var point in stats) point.Revenue = 0m;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<object> GetTotalRevenue(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var revenue = await _clientStatRepository.GetTotalRevenueAsync(clientId);
|
||||
return new { totalRevenue = revenue };
|
||||
}
|
||||
|
||||
public async Task<object> GetClientStatSummary(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var summary = await _clientStatRepository.GetClientStatSummaryAsync(clientId, from, to);
|
||||
summary.OverdueCount = (await _overdueLockRepository.GetOverdueCustomerIdsAsync(clientId, DateTime.UtcNow)).Count;
|
||||
summary.ShowTodayPayments = clientId == MuscleBeastClientId;
|
||||
|
||||
var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue;
|
||||
if (!isOwner)
|
||||
{
|
||||
// Revenue is owner-only, enforced here (not just hidden client-side) — a staff
|
||||
// member calling this endpoint directly still gets zeros, not real figures.
|
||||
summary.ThisMonthRevenue = 0m;
|
||||
summary.LastMonthRevenue = 0m;
|
||||
summary.RevenueGrowthPercentage = 0m;
|
||||
summary.TotalRevenue = 0m;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Helpers;
|
||||
|
||||
namespace Fitness_core_BE.Services.CustomerInvoice
|
||||
{
|
||||
public class CustomerInvoiceService : ICustomerInvoiceService
|
||||
{
|
||||
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public CustomerInvoiceService(ICustomerInvoiceRepository customerInvoiceRepository, IPaymentRepository paymentRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_customerInvoiceRepository = customerInvoiceRepository;
|
||||
_paymentRepository = paymentRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> CreateCustomerInvoiceFromSubscription(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("subscriptionId"))
|
||||
{
|
||||
throw new KeyNotFoundException("subscriptionId is required");
|
||||
}
|
||||
|
||||
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
|
||||
var result = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerInvoiceById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerInvoiceId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerInvoiceId is required");
|
||||
}
|
||||
|
||||
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
|
||||
var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId);
|
||||
|
||||
if (customerInvoice == null)
|
||||
{
|
||||
throw new KeyNotFoundException("CustomerInvoice not found");
|
||||
}
|
||||
|
||||
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoice.CustomerInvoiceId);
|
||||
var carriedForward = await _customerInvoiceRepository.GetCarriedForwardAmountsAsync(
|
||||
new List<Guid> { customerInvoice.CustomerInvoiceId });
|
||||
carriedForward.TryGetValue(customerInvoice.CustomerInvoiceId, out var carriedForwardAmount);
|
||||
|
||||
var link = customerInvoice.CustomerSubscription?.TenantCustomerLink;
|
||||
return new
|
||||
{
|
||||
customerInvoice.CustomerInvoiceId,
|
||||
customerInvoice.FKCustomerSubscriptionId,
|
||||
customerInvoice.FKClientId,
|
||||
customerInvoice.FKUserId,
|
||||
customerInvoice.Amount,
|
||||
// The portion of Amount that's a prior cycle's unpaid balance rolled forward
|
||||
// (vs. this cycle's own charge) -- see SubscriptionRenewalRunner/BuildAndAddInvoice.
|
||||
CarriedForwardAmount = carriedForwardAmount,
|
||||
customerInvoice.Status,
|
||||
customerInvoice.CustomerInvoiceDate,
|
||||
PaidAmount = paid,
|
||||
Balance = customerInvoice.Amount - paid,
|
||||
// Flattened for callers that don't want to reach through
|
||||
// CustomerSubscription.TenantCustomerLink for basic display fields.
|
||||
CustomerName = link?.DisplayName,
|
||||
CustomerPhone = link?.PrimaryPhoneCached,
|
||||
customerInvoice.CustomerSubscription
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerInvoicesByCustomerId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerId is required");
|
||||
}
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var invoices = await _customerInvoiceRepository.GetCustomerInvoicesByCustomerId(customerId);
|
||||
|
||||
// Same enriched shape as GetCustomerInvoicesByClientId -- this now backs a public
|
||||
// "view previous invoices" route the frontend renders as MemberInvoice rows.
|
||||
var result = new List<object>(invoices.Count);
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
|
||||
var link = invoice.CustomerSubscription?.TenantCustomerLink;
|
||||
result.Add(new
|
||||
{
|
||||
invoice.CustomerInvoiceId,
|
||||
invoice.FKCustomerSubscriptionId,
|
||||
invoice.FKClientId,
|
||||
invoice.Amount,
|
||||
invoice.Status,
|
||||
invoice.IsActive,
|
||||
invoice.CustomerInvoiceDate,
|
||||
PaidAmount = paid,
|
||||
Balance = invoice.Amount - paid,
|
||||
CustomerName = link?.DisplayName,
|
||||
CustomerPhone = link?.PrimaryPhoneCached,
|
||||
FKCustomerId = invoice.CustomerSubscription?.FKCustomerId,
|
||||
invoice.CustomerSubscription
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerInvoicesByClientId(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
// scope=="latest": one row per customer (their latest approved invoice) instead of
|
||||
// every invoice they've ever had -- used by the regular invoices tab now that the
|
||||
// renewal-approval workflow can leave older invoices (Paid/RolledOver/Cancelled)
|
||||
// sitting alongside the current one. Default (no scope) keeps today's full-history
|
||||
// behavior for any other existing caller of this endpoint.
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var scope = data != null && data.ContainsKey("scope") ? data["scope"].GetString() : null;
|
||||
|
||||
var invoices = scope == "latest"
|
||||
? await _customerInvoiceRepository.GetLatestApprovedInvoicePerCustomerAsync(clientId)
|
||||
: await _customerInvoiceRepository.GetCustomerInvoicesByClientId(clientId);
|
||||
|
||||
// Enrich each invoice with its approved paid total and outstanding balance,
|
||||
// preserving the nested shape the frontend reads.
|
||||
var result = new List<object>(invoices.Count);
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
|
||||
var link = invoice.CustomerSubscription?.TenantCustomerLink;
|
||||
result.Add(new
|
||||
{
|
||||
invoice.CustomerInvoiceId,
|
||||
invoice.FKCustomerSubscriptionId,
|
||||
invoice.FKClientId,
|
||||
invoice.Amount,
|
||||
invoice.Status,
|
||||
invoice.IsActive,
|
||||
invoice.CustomerInvoiceDate,
|
||||
PaidAmount = paid,
|
||||
Balance = invoice.Amount - paid,
|
||||
// Flattened for callers that don't want to reach through
|
||||
// CustomerSubscription.TenantCustomerLink for basic display fields.
|
||||
CustomerName = link?.DisplayName,
|
||||
CustomerPhone = link?.PrimaryPhoneCached,
|
||||
FKCustomerId = invoice.CustomerSubscription?.FKCustomerId,
|
||||
invoice.CustomerSubscription
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<object> UpdateCustomerInvoiceStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerInvoiceId") || !data.ContainsKey("status"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerInvoiceId and status are required");
|
||||
}
|
||||
|
||||
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
|
||||
var status = data["status"].GetString()!;
|
||||
|
||||
// Validate status
|
||||
var validStatuses = new[] { "Pending", "Partial", "Paid", "Failed", "Cancelled", "Refunded", "RolledOver" };
|
||||
if (!validStatuses.Contains(status))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}");
|
||||
}
|
||||
|
||||
var result = await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<object> CalculateCustomerInvoiceTotal(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerInvoiceId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerInvoiceId is required");
|
||||
}
|
||||
|
||||
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
|
||||
var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId);
|
||||
var total = customerInvoice?.Amount ?? 0;
|
||||
|
||||
return new { Total = total };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.DTOs.CustomerPlan;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Helpers;
|
||||
|
||||
|
||||
namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
{
|
||||
public class CustomerPlanService : ICustomerPlanService
|
||||
{
|
||||
private const string ScopeIndividual = "Individual";
|
||||
private const string ScopeGroup = "Group";
|
||||
private const string ModeRolling = "Rolling";
|
||||
private const string ModeFixedRange = "FixedRange";
|
||||
private const string RolloverNone = "NoRollover";
|
||||
private const string RolloverGrace = "GracePeriod";
|
||||
private const string BillingPrepaid = "Prepaid";
|
||||
private const string BillingPostpaid = "Postpaid";
|
||||
|
||||
private readonly ICustomerPlanRepository _customerPlanRepository;
|
||||
private readonly IPlaceRepo _placeRepo;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public CustomerPlanService(
|
||||
ICustomerPlanRepository customerPlanRepository,
|
||||
IPlaceRepo placeRepo,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_customerPlanRepository = customerPlanRepository;
|
||||
_placeRepo = placeRepo;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
bool includeInactive = data != null &&
|
||||
data.ContainsKey("includeInactive") &&
|
||||
data["includeInactive"].GetBoolean();
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid contextClientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
Guid? requestedClientId = null;
|
||||
if (data != null && data.ContainsKey("FKClientId") && data["FKClientId"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
requestedClientId = Guid.Parse(data["FKClientId"].GetString()!);
|
||||
}
|
||||
|
||||
var clientId = requestedClientId ?? contextClientId;
|
||||
|
||||
var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId);
|
||||
|
||||
var placeIdsByPlan = await _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList());
|
||||
var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList();
|
||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds);
|
||||
|
||||
return plans.Select(p =>
|
||||
{
|
||||
var placeIds = placeIdsByPlan.GetValueOrDefault(p.CustomerPlanId) ?? new List<Guid>();
|
||||
return MapToDto(p, p.Client?.ClientName, placeIds, placeNames);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerPlanById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerPlanId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerPlanId is required");
|
||||
}
|
||||
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
var plan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId);
|
||||
|
||||
if (plan == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Plan not found");
|
||||
}
|
||||
|
||||
return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName);
|
||||
}
|
||||
|
||||
public async Task<object> CreateCustomerPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("planName"))
|
||||
{
|
||||
throw new KeyNotFoundException("planName is required");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("planPrice"))
|
||||
{
|
||||
throw new KeyNotFoundException("planPrice is required");
|
||||
}
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var customerPlan = new Models.CustomerPlan
|
||||
{
|
||||
FKClientId = clientId,
|
||||
PlanName = data["planName"].GetString(),
|
||||
PlanDescription = data.ContainsKey("planDescription")
|
||||
? data["planDescription"].GetString()
|
||||
: null,
|
||||
PlanPrice = data["planPrice"].GetDecimal(),
|
||||
HasOnboardingFee = data.ContainsKey("hasOnboardingFee") && data["hasOnboardingFee"].GetBoolean(),
|
||||
OnboardingFeeAmount = data.ContainsKey("onboardingFeeAmount") ? data["onboardingFeeAmount"].GetDecimal() : 0m,
|
||||
PlanScope = data.ContainsKey("planScope") ? data["planScope"].GetString() ?? ScopeIndividual : ScopeIndividual,
|
||||
MemberLimit = data.ContainsKey("memberLimit") ? data["memberLimit"].GetInt32() : 1,
|
||||
DurationMode = data.ContainsKey("durationMode") ? data["durationMode"].GetString() ?? ModeRolling : ModeRolling,
|
||||
DurationInDays = data.ContainsKey("durationInDays") ? data["durationInDays"].GetInt32() : 0,
|
||||
ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"),
|
||||
ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"),
|
||||
AllowSplitPayments = data.ContainsKey("allowSplitPayments") && data["allowSplitPayments"].GetBoolean(),
|
||||
InstallmentCount = data.ContainsKey("installmentCount") ? data["installmentCount"].GetInt32() : 0,
|
||||
SessionsPerCycle = data.ContainsKey("sessionsPerCycle") && data["sessionsPerCycle"].ValueKind != JsonValueKind.Null
|
||||
? data["sessionsPerCycle"].GetInt32()
|
||||
: null,
|
||||
SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone,
|
||||
RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0,
|
||||
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid
|
||||
};
|
||||
|
||||
NormalizeAndValidate(customerPlan);
|
||||
|
||||
var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||
if (placeIds != null)
|
||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// Fetch the created plan with client details
|
||||
var createdPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId);
|
||||
|
||||
return await MapToDtoWithPlacesAsync(result, createdPlan?.Client?.ClientName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateCustomerPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerPlanId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerPlanId is required");
|
||||
}
|
||||
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
|
||||
var existingPlan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId);
|
||||
if (existingPlan == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Plan not found");
|
||||
}
|
||||
|
||||
// Merge: start from existing values, override only the provided keys,
|
||||
// then re-validate the combined configuration.
|
||||
if (data.ContainsKey("planName")) existingPlan.PlanName = data["planName"].GetString();
|
||||
if (data.ContainsKey("planDescription")) existingPlan.PlanDescription = data["planDescription"].GetString();
|
||||
if (data.ContainsKey("planPrice")) existingPlan.PlanPrice = data["planPrice"].GetDecimal();
|
||||
if (data.ContainsKey("hasOnboardingFee")) existingPlan.HasOnboardingFee = data["hasOnboardingFee"].GetBoolean();
|
||||
if (data.ContainsKey("onboardingFeeAmount")) existingPlan.OnboardingFeeAmount = data["onboardingFeeAmount"].GetDecimal();
|
||||
if (data.ContainsKey("planScope")) existingPlan.PlanScope = data["planScope"].GetString() ?? existingPlan.PlanScope;
|
||||
if (data.ContainsKey("memberLimit")) existingPlan.MemberLimit = data["memberLimit"].GetInt32();
|
||||
if (data.ContainsKey("durationMode")) existingPlan.DurationMode = data["durationMode"].GetString() ?? existingPlan.DurationMode;
|
||||
if (data.ContainsKey("durationInDays")) existingPlan.DurationInDays = data["durationInDays"].GetInt32();
|
||||
if (data.ContainsKey("validFrom")) existingPlan.ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom");
|
||||
if (data.ContainsKey("validTo")) existingPlan.ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo");
|
||||
if (data.ContainsKey("allowSplitPayments")) existingPlan.AllowSplitPayments = data["allowSplitPayments"].GetBoolean();
|
||||
if (data.ContainsKey("installmentCount")) existingPlan.InstallmentCount = data["installmentCount"].GetInt32();
|
||||
if (data.ContainsKey("sessionsPerCycle"))
|
||||
existingPlan.SessionsPerCycle = data["sessionsPerCycle"].ValueKind == JsonValueKind.Null ? null : data["sessionsPerCycle"].GetInt32();
|
||||
if (data.ContainsKey("sessionRolloverPolicy")) existingPlan.SessionRolloverPolicy = data["sessionRolloverPolicy"].GetString() ?? existingPlan.SessionRolloverPolicy;
|
||||
if (data.ContainsKey("rolloverGraceDays")) existingPlan.RolloverGraceDays = data["rolloverGraceDays"].GetInt32();
|
||||
if (data.ContainsKey("billingTiming")) existingPlan.BillingTiming = data["billingTiming"].GetString() ?? existingPlan.BillingTiming;
|
||||
if (data.ContainsKey("isActive")) existingPlan.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
NormalizeAndValidate(existingPlan);
|
||||
|
||||
var result = await _customerPlanRepository.UpdateCustomerPlan(existingPlan);
|
||||
|
||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||
if (placeIds != null)
|
||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var updatedPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId);
|
||||
|
||||
return await MapToDtoWithPlacesAsync(result, updatedPlan?.Client?.ClientName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetCustomerPlanStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerPlanId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerPlanId is required");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("isActive"))
|
||||
{
|
||||
throw new KeyNotFoundException("isActive is required");
|
||||
}
|
||||
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
var isActive = data["isActive"].GetBoolean();
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
// Scoped to the caller's gym, so a plan id from another gym reads as
|
||||
// "not found" rather than being toggled.
|
||||
var plan = await _customerPlanRepository.SetCustomerPlanActive(customerPlanId, clientId, isActive);
|
||||
|
||||
if (plan == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Plan not found");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> DeleteCustomerPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerPlanId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerPlanId is required");
|
||||
}
|
||||
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
|
||||
// Check if hard delete is requested (default is soft delete)
|
||||
bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean();
|
||||
|
||||
var result = await _customerPlanRepository.DeleteCustomerPlan(customerPlanId, softDelete);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Plan not found");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = softDelete
|
||||
? "Customer Plan deactivated successfully"
|
||||
: "Customer Plan deleted successfully",
|
||||
softDelete = softDelete
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the cross-field rules of the plan configuration and fills
|
||||
/// derived defaults (member limit, installment count, cleared date range).
|
||||
/// Throws ArgumentException on invalid combinations.
|
||||
/// </summary>
|
||||
private static void NormalizeAndValidate(Models.CustomerPlan plan)
|
||||
{
|
||||
// Scope
|
||||
if (plan.PlanScope != ScopeIndividual && plan.PlanScope != ScopeGroup)
|
||||
throw new ArgumentException($"planScope must be \"{ScopeIndividual}\" or \"{ScopeGroup}\"");
|
||||
|
||||
if (plan.PlanScope == ScopeGroup)
|
||||
{
|
||||
if (plan.MemberLimit < 2)
|
||||
throw new ArgumentException("memberLimit must be at least 2 for Group plans (couple = 2)");
|
||||
}
|
||||
else
|
||||
{
|
||||
plan.MemberLimit = 1;
|
||||
}
|
||||
|
||||
// Duration
|
||||
if (plan.DurationMode != ModeRolling && plan.DurationMode != ModeFixedRange)
|
||||
throw new ArgumentException($"durationMode must be \"{ModeRolling}\" or \"{ModeFixedRange}\"");
|
||||
|
||||
int effectiveDays;
|
||||
if (plan.DurationMode == ModeRolling)
|
||||
{
|
||||
if (plan.DurationInDays < 1)
|
||||
throw new ArgumentException("durationInDays (>= 1) is required for Rolling plans");
|
||||
plan.ValidFrom = null;
|
||||
plan.ValidTo = null;
|
||||
effectiveDays = plan.DurationInDays;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (plan.ValidFrom == null || plan.ValidTo == null)
|
||||
throw new ArgumentException("validFrom and validTo are required for FixedRange plans");
|
||||
if (plan.ValidTo <= plan.ValidFrom)
|
||||
throw new ArgumentException("validTo must be after validFrom");
|
||||
plan.DurationInDays = 0;
|
||||
effectiveDays = (int)(plan.ValidTo.Value - plan.ValidFrom.Value).TotalDays;
|
||||
}
|
||||
|
||||
if (plan.PlanPrice < 0)
|
||||
throw new ArgumentException("planPrice must be a positive value");
|
||||
|
||||
if (plan.OnboardingFeeAmount < 0)
|
||||
throw new ArgumentException("onboardingFeeAmount must be zero or greater");
|
||||
if (!plan.HasOnboardingFee)
|
||||
plan.OnboardingFeeAmount = 0m;
|
||||
else if (plan.OnboardingFeeAmount <= 0)
|
||||
throw new ArgumentException("onboardingFeeAmount must be greater than zero when hasOnboardingFee is true");
|
||||
|
||||
// Split payments: mostly-equal installments (e.g. Rs. 6000 / 6 months -> 6 x Rs. 1000).
|
||||
if (plan.AllowSplitPayments)
|
||||
{
|
||||
if (plan.InstallmentCount < 2)
|
||||
{
|
||||
// Default: one installment per month of the plan's duration.
|
||||
plan.InstallmentCount = Math.Max(2, (int)Math.Round(effectiveDays / 30.0));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
plan.InstallmentCount = 1;
|
||||
}
|
||||
|
||||
// Session-count / rollover / billing timing (new SRS requirements).
|
||||
if (plan.SessionRolloverPolicy != RolloverNone && plan.SessionRolloverPolicy != RolloverGrace)
|
||||
throw new ArgumentException($"sessionRolloverPolicy must be \"{RolloverNone}\" or \"{RolloverGrace}\"");
|
||||
|
||||
if (plan.SessionsPerCycle.HasValue)
|
||||
{
|
||||
if (plan.SessionsPerCycle.Value < 1)
|
||||
throw new ArgumentException("sessionsPerCycle must be at least 1 when set");
|
||||
|
||||
if (plan.SessionRolloverPolicy == RolloverGrace && plan.RolloverGraceDays < 1)
|
||||
throw new ArgumentException("rolloverGraceDays must be at least 1 when sessionRolloverPolicy is \"GracePeriod\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No session allowance on this plan (unlimited-access, existing gym behavior) --
|
||||
// rollover settings are meaningless without a per-cycle count to roll over.
|
||||
plan.SessionRolloverPolicy = RolloverNone;
|
||||
plan.RolloverGraceDays = 0;
|
||||
}
|
||||
|
||||
if (plan.BillingTiming != BillingPrepaid && plan.BillingTiming != BillingPostpaid)
|
||||
throw new ArgumentException($"billingTiming must be \"{BillingPrepaid}\" or \"{BillingPostpaid}\"");
|
||||
}
|
||||
|
||||
private async Task<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
|
||||
{
|
||||
var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId);
|
||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds);
|
||||
return MapToDto(plan, clientName, placeIds, placeNames);
|
||||
}
|
||||
|
||||
private static CustomerPlanDto MapToDto(
|
||||
Models.CustomerPlan plan,
|
||||
string? clientName,
|
||||
List<Guid> placeIds,
|
||||
Dictionary<Guid, string> placeNames) => new()
|
||||
{
|
||||
CustomerPlanId = plan.CustomerPlanId,
|
||||
FKClientId = plan.FKClientId,
|
||||
ClientName = clientName,
|
||||
PlanName = plan.PlanName,
|
||||
PlanDescription = plan.PlanDescription,
|
||||
PlanPrice = plan.PlanPrice,
|
||||
HasOnboardingFee = plan.HasOnboardingFee,
|
||||
OnboardingFeeAmount = plan.OnboardingFeeAmount,
|
||||
DurationInDays = plan.DurationInDays,
|
||||
PlanScope = plan.PlanScope,
|
||||
MemberLimit = plan.MemberLimit,
|
||||
DurationMode = plan.DurationMode,
|
||||
ValidFrom = plan.ValidFrom,
|
||||
ValidTo = plan.ValidTo,
|
||||
AllowSplitPayments = plan.AllowSplitPayments,
|
||||
InstallmentCount = plan.InstallmentCount,
|
||||
SessionsPerCycle = plan.SessionsPerCycle,
|
||||
SessionRolloverPolicy = plan.SessionRolloverPolicy,
|
||||
RolloverGraceDays = plan.RolloverGraceDays,
|
||||
BillingTiming = plan.BillingTiming,
|
||||
IsActive = plan.IsActive,
|
||||
CreatedAt = plan.CreatedAt,
|
||||
PlaceIds = placeIds,
|
||||
PlaceNames = placeIds.Select(id => placeNames.GetValueOrDefault(id, "")).Where(n => n != "").ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
using Fitness_core_BE.Domain;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.DTOs.CustomerSubscription;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services.CustomerSubscription
|
||||
{
|
||||
public class CustomerSubscriptionService : ICustomerSubscriptionService
|
||||
{
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
||||
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public CustomerSubscriptionService(
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
ICustomerInvoiceRepository customerInvoiceRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
_customerInvoiceRepository = customerInvoiceRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> CreateCustomerSubscription(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerId is required");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("customerPlanId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerPlanId is required");
|
||||
}
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||
|
||||
// Get plan details for creating the initial sub-item
|
||||
var plan = await _customerSubscriptionRepository
|
||||
.GetPlanByIdAsync(customerPlanId);
|
||||
if (plan == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Plan not found");
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
var subscription = new Models.CustomerSubscription
|
||||
{
|
||||
FKCustomerId = customerId,
|
||||
FKCustomerPlanId = customerPlanId,
|
||||
OnboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate") ?? DateTime.UtcNow,
|
||||
ExpiryDate = DataExtractor.GetOptionalDateTime(data, "expiryDate"),
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// Create initial sub-item representing the main plan
|
||||
var initialSubItem = new CustomerSubItem
|
||||
{
|
||||
SubItemName = plan.PlanName,
|
||||
SubItemDescription = plan.PlanDescription,
|
||||
SubItemPrice = plan.PlanPrice,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var aggregate = CustomerSubscriptionFactory.Create(
|
||||
subscription, plan, initialSubItem);
|
||||
await _customerSubscriptionRepository.AddCustomerSubscriptionAsync(
|
||||
aggregate.Subscription,
|
||||
aggregate.InitialSubItem,
|
||||
aggregate.PrimaryMember,
|
||||
aggregate.Installments);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
subscriptionId = subscription.SubscriptionId,
|
||||
customerId = subscription.FKCustomerId,
|
||||
customerPlanId = subscription.FKCustomerPlanId,
|
||||
onboardDate = subscription.OnboardDate,
|
||||
expiryDate = subscription.ExpiryDate,
|
||||
isActive = subscription.IsActive,
|
||||
message = "Customer subscription created successfully with initial plan item"
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerSubscriptionById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("subscriptionId"))
|
||||
{
|
||||
throw new KeyNotFoundException("subscriptionId is required");
|
||||
}
|
||||
|
||||
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Customer Subscription not found");
|
||||
}
|
||||
|
||||
// Get sub-items
|
||||
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
|
||||
subscriptionId, activeOnly: false, newestFirst: false))
|
||||
.Select(ToSubItemDto)
|
||||
.ToList();
|
||||
|
||||
return new CustomerSubscriptionDto
|
||||
{
|
||||
SubscriptionId = subscription.SubscriptionId,
|
||||
CustomerId = subscription.FKCustomerId,
|
||||
CustomerName = subscription.TenantCustomerLink?.DisplayName,
|
||||
CustomerPlanId = subscription.FKCustomerPlanId,
|
||||
PlanName = subscription.CustomerPlan?.PlanName,
|
||||
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
|
||||
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
|
||||
OnboardDate = subscription.OnboardDate,
|
||||
ExpiryDate = subscription.ExpiryDate,
|
||||
RenewalDate = subscription.RenewalDate,
|
||||
IsActive = subscription.IsActive,
|
||||
CreatedAt = subscription.CreatedAt,
|
||||
DiscountType = subscription.DiscountType,
|
||||
DiscountValue = subscription.DiscountValue,
|
||||
SubItems = subItems
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<object> GetCustomerSubscriptionsByCustomerId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerId is required");
|
||||
}
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var subscriptions = await _customerSubscriptionRepository.GetCustomerSubscriptionsByCustomerId(customerId);
|
||||
|
||||
return subscriptions.Select(s => new
|
||||
{
|
||||
subscriptionId = s.SubscriptionId,
|
||||
customerPlanId = s.FKCustomerPlanId,
|
||||
planName = s.CustomerPlan?.PlanName,
|
||||
planPrice = s.CustomerPlan?.PlanPrice,
|
||||
durationInDays = s.CustomerPlan?.DurationInDays,
|
||||
onboardDate = s.OnboardDate,
|
||||
expiryDate = s.ExpiryDate,
|
||||
renewalDate = s.RenewalDate,
|
||||
isActive = s.IsActive,
|
||||
createdAt = s.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetActiveSubscriptionByCustomerId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("customerId"))
|
||||
{
|
||||
throw new KeyNotFoundException("customerId is required");
|
||||
}
|
||||
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return new { message = "No active subscription found for this customer" };
|
||||
}
|
||||
|
||||
// Get sub-items
|
||||
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
|
||||
subscription.SubscriptionId,
|
||||
activeOnly: false,
|
||||
newestFirst: false))
|
||||
.Select(ToSubItemDto)
|
||||
.ToList();
|
||||
|
||||
return new CustomerSubscriptionDto
|
||||
{
|
||||
SubscriptionId = subscription.SubscriptionId,
|
||||
CustomerId = subscription.FKCustomerId,
|
||||
CustomerName = subscription.TenantCustomerLink?.DisplayName,
|
||||
CustomerPlanId = subscription.FKCustomerPlanId,
|
||||
PlanName = subscription.CustomerPlan?.PlanName,
|
||||
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
|
||||
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
|
||||
OnboardDate = subscription.OnboardDate,
|
||||
ExpiryDate = subscription.ExpiryDate,
|
||||
RenewalDate = subscription.RenewalDate,
|
||||
IsActive = subscription.IsActive,
|
||||
CreatedAt = subscription.CreatedAt,
|
||||
DiscountType = subscription.DiscountType,
|
||||
DiscountValue = subscription.DiscountValue,
|
||||
SubItems = subItems
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Group membership & summaries (client dashboard) ----------
|
||||
|
||||
public async Task<object> GetSubscriptionSummary(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
||||
|
||||
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
||||
return await BuildSummaryAsync(subscription);
|
||||
}
|
||||
|
||||
public async Task<object> AddMemberToSubscription(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
|
||||
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
||||
var plan = subscription.CustomerPlan;
|
||||
|
||||
if (plan == null || plan.PlanScope != "Group")
|
||||
throw new ArgumentException("Members can only be added to Group plans");
|
||||
|
||||
var activeCount = await _customerSubscriptionRepository.GetActiveMemberCountAsync(subscriptionId);
|
||||
if (activeCount >= plan.MemberLimit)
|
||||
throw new ArgumentException($"This plan allows at most {plan.MemberLimit} members");
|
||||
|
||||
var customerExists = await _customerSubscriptionRepository
|
||||
.IsActiveCustomerAsync(customerId);
|
||||
if (!customerExists)
|
||||
throw new KeyNotFoundException("Customer not found");
|
||||
|
||||
var alreadyMember = await _customerSubscriptionRepository
|
||||
.GetActiveSubscriptionMemberByCustomerAsync(subscriptionId, customerId);
|
||||
if (alreadyMember != null)
|
||||
throw new ArgumentException("This customer is already a member of the subscription");
|
||||
|
||||
// One customer, one plan at a time -- catches both "already primary on their own
|
||||
// plan" and "already a secondary on a different group", neither of which the
|
||||
// check above sees (it only looks at this one subscription).
|
||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
||||
if (hasOtherActivePlan)
|
||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
|
||||
|
||||
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
|
||||
{
|
||||
FKSubscriptionId = subscriptionId,
|
||||
FKCustomerId = customerId,
|
||||
IsPrimary = false
|
||||
});
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
subscriptionMemberId = member.SubscriptionMemberId,
|
||||
memberCount = activeCount + 1,
|
||||
memberLimit = plan.MemberLimit
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> RemoveMemberFromSubscription(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
||||
var subscriptionMemberId = Guid.Parse(data["subscriptionMemberId"].GetString()!);
|
||||
|
||||
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
||||
|
||||
var member = await _customerSubscriptionRepository.GetSubscriptionMemberAsync(subscriptionMemberId);
|
||||
if (member == null || member.FKSubscriptionId != subscriptionId || !member.IsActive)
|
||||
throw new KeyNotFoundException("Subscription member not found");
|
||||
|
||||
if (member.IsPrimary)
|
||||
throw new ArgumentException("The primary member cannot be removed from a subscription");
|
||||
|
||||
member.IsActive = false;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return new { success = true };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Typed variant used by ClientService.GetCustomerDetailsById (covers group secondaries).</summary>
|
||||
public async Task<List<SubscriptionSummaryDto>> BuildSummariesForCustomerAsync(Guid customerId)
|
||||
{
|
||||
var subscriptions = await _customerSubscriptionRepository.GetSubscriptionsForCustomerAsync(customerId);
|
||||
var summaries = new List<SubscriptionSummaryDto>(subscriptions.Count);
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
summaries.Add(await BuildSummaryAsync(subscription));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public async Task<List<CustomerSubItemDto>> GetExtraFeesAsync(Guid subscriptionId, HttpContext httpContext)
|
||||
{
|
||||
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
||||
|
||||
return (await _customerSubscriptionRepository.GetSubItemsAsync(
|
||||
subscriptionId, activeOnly: true, newestFirst: true))
|
||||
.Select(ToSubItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<object> AddExtraFeeAsync(Guid subscriptionId, AddExtraFeeRequest request, HttpContext httpContext)
|
||||
{
|
||||
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
throw new ArgumentException("A name is required for the extra fee.");
|
||||
if (request.Amount <= 0)
|
||||
throw new ArgumentException("Amount must be greater than zero.");
|
||||
if (request.Frequency != "OneTime" && request.Frequency != "Recurring")
|
||||
throw new ArgumentException("Frequency must be 'OneTime' or 'Recurring'.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var subItem = new CustomerSubItem
|
||||
{
|
||||
CustomerSubItemId = Guid.NewGuid(),
|
||||
FKCustomerSubscriptionId = subscriptionId,
|
||||
SubItemName = request.Name,
|
||||
SubItemDescription = request.Description,
|
||||
SubItemPrice = request.Amount,
|
||||
Frequency = request.Frequency,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _customerSubscriptionRepository.AddSubItemAsync(subItem);
|
||||
|
||||
// Charged immediately as its own invoice — the subscription's original
|
||||
// invoice is idempotent and won't retroactively pick up items added later.
|
||||
var invoice = await _customerInvoiceRepository.CreateSupplementalInvoiceAsync(
|
||||
subscriptionId, request.Name, request.Description, request.Amount);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
customerSubItemId = subItem.CustomerSubItemId,
|
||||
customerInvoiceId = invoice.CustomerInvoiceId,
|
||||
amount = invoice.Amount,
|
||||
frequency = subItem.Frequency
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Models.CustomerSubscription> LoadAuthorizedSubscription(Guid subscriptionId, HttpContext httpContext)
|
||||
{
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
|
||||
if (subscription == null)
|
||||
throw new KeyNotFoundException("Subscription not found");
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId)
|
||||
throw new UnauthorizedAccessException("This subscription does not belong to your gym");
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async Task<SubscriptionSummaryDto> BuildSummaryAsync(Models.CustomerSubscription subscription)
|
||||
{
|
||||
var plan = subscription.CustomerPlan;
|
||||
|
||||
var invoice = await _customerSubscriptionRepository
|
||||
.GetLatestInvoiceAsync(subscription.SubscriptionId);
|
||||
|
||||
decimal invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m;
|
||||
decimal paid = 0m;
|
||||
if (invoice != null)
|
||||
{
|
||||
paid = await _customerSubscriptionRepository
|
||||
.GetApprovedPaymentTotalAsync(invoice.CustomerInvoiceId);
|
||||
}
|
||||
|
||||
var members = (subscription.SubscriptionMembers ?? new List<SubscriptionMember>())
|
||||
.Where(m => m.IsActive)
|
||||
.OrderByDescending(m => m.IsPrimary)
|
||||
.ThenBy(m => m.JoinedAt)
|
||||
.Select(m => new SubscriptionMemberDto
|
||||
{
|
||||
SubscriptionMemberId = m.SubscriptionMemberId,
|
||||
CustomerId = m.FKCustomerId,
|
||||
CusName = m.TenantCustomerLink?.DisplayName,
|
||||
Email = m.TenantCustomerLink?.EmailCached,
|
||||
PrimaryPH = m.TenantCustomerLink?.PrimaryPhoneCached,
|
||||
IsPrimary = m.IsPrimary,
|
||||
JoinedAt = m.JoinedAt,
|
||||
IsActive = m.IsActive,
|
||||
IsPlanAssigned = m.IsPlanAssigned
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var installments = (subscription.SubscriptionInstallments ?? new List<SubscriptionInstallment>())
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.InstallmentNo)
|
||||
.Select(i => new SubscriptionInstallmentDto
|
||||
{
|
||||
SubscriptionInstallmentId = i.SubscriptionInstallmentId,
|
||||
InstallmentNo = i.InstallmentNo,
|
||||
DueDate = i.DueDate,
|
||||
Amount = i.Amount,
|
||||
PaidAmount = i.PaidAmount,
|
||||
Status = i.Status
|
||||
})
|
||||
.ToList();
|
||||
|
||||
AutoChargeCardDto? autoChargeCard = null;
|
||||
if (subscription.FKAutoChargeCardId.HasValue)
|
||||
{
|
||||
var card = await _customerSubscriptionRepository
|
||||
.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value);
|
||||
if (card != null)
|
||||
{
|
||||
autoChargeCard = new AutoChargeCardDto
|
||||
{
|
||||
CardId = card.JobId,
|
||||
CardLast4 = card.CardNumber,
|
||||
CardType = card.CardType
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new SubscriptionSummaryDto
|
||||
{
|
||||
SubscriptionId = subscription.SubscriptionId,
|
||||
CustomerPlanId = subscription.FKCustomerPlanId,
|
||||
PlanName = plan?.PlanName,
|
||||
PlanScope = plan?.PlanScope ?? "Individual",
|
||||
MemberLimit = plan?.MemberLimit ?? 1,
|
||||
DurationMode = plan?.DurationMode ?? "Rolling",
|
||||
AllowSplitPayments = plan?.AllowSplitPayments ?? false,
|
||||
PlanPrice = plan?.PlanPrice ?? 0m,
|
||||
OnboardDate = subscription.OnboardDate,
|
||||
ExpiryDate = subscription.ExpiryDate,
|
||||
RenewalDate = subscription.RenewalDate,
|
||||
IsActive = subscription.IsActive,
|
||||
CustomerInvoiceId = invoice?.CustomerInvoiceId,
|
||||
InvoiceAmount = invoiceAmount,
|
||||
PaidAmount = paid,
|
||||
Balance = invoiceAmount - paid,
|
||||
PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0
|
||||
? "Paid"
|
||||
: paid > 0 ? "Partial" : "Pending",
|
||||
Members = members,
|
||||
Installments = installments,
|
||||
AutoChargeEnabled = subscription.AutoChargeEnabled,
|
||||
AutoChargeCard = autoChargeCard
|
||||
};
|
||||
}
|
||||
|
||||
private static CustomerSubItemDto ToSubItemDto(CustomerSubItem item) => new()
|
||||
{
|
||||
CustomerSubItemId = item.CustomerSubItemId,
|
||||
SubItemName = item.SubItemName,
|
||||
SubItemDescription = item.SubItemDescription,
|
||||
SubItemPrice = item.SubItemPrice,
|
||||
Frequency = item.Frequency,
|
||||
IsActive = item.IsActive,
|
||||
CreatedAt = item.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// add function to add new sub-item to existing subscription, which can be used for add-on purchases
|
||||
@@ -0,0 +1,263 @@
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.DTOs;
|
||||
|
||||
namespace Fitness_core_BE.Services.Document
|
||||
{
|
||||
public class DocumentService : IDocumentService
|
||||
{
|
||||
private readonly IDocumentRepository _docRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public DocumentService(IDocumentRepository docRepository, IUnitOfWork uow)
|
||||
{
|
||||
_docRepository = docRepository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<object> UploadDocument(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
var files = data["document"].EnumerateArray();
|
||||
bool isTicketDoc = data.TryGetValue("IsTicketDoc", out JsonElement isTicketDocElement) && isTicketDocElement.GetBoolean();
|
||||
var uploadResults = new List<object>();
|
||||
Guid? ticketId = null;
|
||||
if (isTicketDoc && data.TryGetValue("TicketId", out JsonElement ticketIdElement))
|
||||
{
|
||||
ticketId = ticketIdElement.GetGuid();
|
||||
}
|
||||
string uploadsRoot = "./desktop";
|
||||
|
||||
|
||||
if (isTicketDoc && ticketId.HasValue)
|
||||
{
|
||||
// uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString(), ticketId.ToString()));
|
||||
uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString(), ticketId.Value.ToString()));
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
// uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString()));
|
||||
uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString()));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(uploadsRoot))
|
||||
{
|
||||
Directory.CreateDirectory(uploadsRoot);
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
string lastnum = await _docRepository.GetLastSequnce();
|
||||
string originalFileName = file.GetProperty("FileName").GetString() ?? "document";
|
||||
string extension = Path.GetExtension(originalFileName).ToLowerInvariant();
|
||||
|
||||
string[] allowedExtensions = { ".pdf", ".jpg", ".jpeg", ".png", ".docx", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp" };
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
throw new BadRequestException("File type not allowed.");
|
||||
|
||||
string safeTimestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
string filename = $"{lastnum}_{userId}_{safeTimestamp}{extension}";
|
||||
|
||||
string baseFile = file.GetProperty("Base64").GetString();
|
||||
if (baseFile.Contains(","))
|
||||
baseFile = baseFile.Substring(baseFile.IndexOf(",") + 1);
|
||||
byte[] fileBytes = Convert.FromBase64String(baseFile);
|
||||
|
||||
if (fileBytes.Length > 10 * 1024 * 1024)
|
||||
throw new BadRequestException("File too large.");
|
||||
|
||||
string filePath = Path.Combine(uploadsRoot, filename);
|
||||
await File.WriteAllBytesAsync(filePath, fileBytes);
|
||||
|
||||
// Extract FileType from the file element
|
||||
var fileData = new Dictionary<string, JsonElement> { { "FileType", file.GetProperty("FileType") } };
|
||||
int docTypeId = DataExtractor.GetRequiredInt(fileData, "FileType");
|
||||
|
||||
UserDocument documentMeta = new UserDocument { };
|
||||
|
||||
if (isTicketDoc && ticketId.HasValue)
|
||||
{
|
||||
documentMeta = new UserDocument
|
||||
{
|
||||
UserId = userId,
|
||||
DocumentName = originalFileName,
|
||||
DocumentUrl = $"/Uploads/{userId}/{ticketId.Value}/{Uri.EscapeDataString(filename)}",
|
||||
FkDocumentTypeId = docTypeId,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
documentMeta = new UserDocument
|
||||
{
|
||||
UserId = userId,
|
||||
DocumentName = originalFileName,
|
||||
DocumentUrl = $"/Uploads/{userId}/{Uri.EscapeDataString(filename)}",
|
||||
FkDocumentTypeId = docTypeId,
|
||||
};
|
||||
}
|
||||
|
||||
var savedDocument = await _docRepository.UploadDocumentAsync(documentMeta);
|
||||
await _uow.SaveChangesAsync();
|
||||
uploadResults.Add(new
|
||||
{
|
||||
savedDocument.DocumentId,
|
||||
savedDocument.DocumentName,
|
||||
DocumentURL = savedDocument.DocumentUrl
|
||||
});
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return new { Message = "Documents uploaded successfully.", uploadResults };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetDocumentsByUserId(object payload, HttpContext httpContext)
|
||||
{
|
||||
// TODO: Uncomment for production - Currently using static userId for testing
|
||||
// if (!Guid.TryParse(httpContext.User.FindFirst("UserId")?.Value, out Guid userId))
|
||||
// {
|
||||
// throw new InvalidOperationException("Invalid UserId in token.");
|
||||
// }
|
||||
|
||||
Guid userId = Guid.Parse("5b664468-9ba5-4e6e-b73a-97c9a791c9a0");
|
||||
|
||||
List<UserDocument> docResults = await _docRepository.GetDocumentsByUserId(userId);
|
||||
|
||||
if (docResults == null || docResults.Count == 0)
|
||||
return new List<object>();
|
||||
|
||||
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
|
||||
|
||||
var documents = new List<object>();
|
||||
|
||||
foreach (var doc in docResults)
|
||||
{
|
||||
if (string.IsNullOrEmpty(doc.DocumentUrl))
|
||||
continue;
|
||||
|
||||
string relativePath = doc.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
|
||||
Console.WriteLine("Relative Path: " + relativePath);
|
||||
|
||||
string filePath = uploadsRoot + relativePath;
|
||||
Console.WriteLine("File Path: " + filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
continue; // Skip missing files safely
|
||||
|
||||
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||
|
||||
documents.Add(new
|
||||
{
|
||||
FileName = doc.DocumentName,
|
||||
FileType = Path.GetExtension(doc.DocumentName),
|
||||
doc.DocumentUrl,
|
||||
doc.DocumentType.DocumentType,
|
||||
Data = Convert.ToBase64String(fileBytes)
|
||||
});
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
public async Task<object> GetDocumentById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var documentId = DataExtractor.GetRequiredInt(data, "documentId");
|
||||
var document = await _docRepository.GetDocumentById(documentId);
|
||||
|
||||
if (document != null && !string.IsNullOrEmpty(document.DocumentUrl))
|
||||
{
|
||||
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
|
||||
Console.WriteLine("Uploads Root: " + uploadsRoot);
|
||||
|
||||
string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
|
||||
Console.WriteLine("Relative Path: " + relativePath);
|
||||
|
||||
string filePath = uploadsRoot + relativePath;
|
||||
Console.WriteLine("File Path: " + filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException("Document file not found.");
|
||||
|
||||
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||
|
||||
return new
|
||||
{
|
||||
FileName = document.DocumentName,
|
||||
FileType = Path.GetExtension(document.DocumentName),
|
||||
document.DocumentUrl,
|
||||
document.DocumentType.DocumentType,
|
||||
Data = Convert.ToBase64String(fileBytes)
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Document not found or invalid document data.");
|
||||
}
|
||||
|
||||
public async Task<object> RemoveDocument(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var documentId = DataExtractor.GetRequiredInt(data, "documentId");
|
||||
var result = await _docRepository.RemoveDocument(documentId);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
throw new NotFoundException("Document not found");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { success = true, message = "Document removed successfully" };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DocumentDownloadModel> GetFileBytesById(int documentId)
|
||||
{
|
||||
var document = await _docRepository.GetDocumentById(documentId);
|
||||
|
||||
if (document == null || string.IsNullOrEmpty(document.DocumentUrl))
|
||||
throw new NotFoundException("Document record not found.");
|
||||
|
||||
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
|
||||
string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
|
||||
string filePath = uploadsRoot + relativePath;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
throw new FileNotFoundException("Physical file is missing on the server.");
|
||||
|
||||
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||
|
||||
return new DocumentDownloadModel
|
||||
{
|
||||
FileName = document.DocumentName,
|
||||
FileType = Path.GetExtension(document.DocumentName),
|
||||
FileData = fileBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Fitness_core_BE.DTOs.Facility;
|
||||
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 System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class FacilityService : IFacilityService
|
||||
{
|
||||
private static readonly string[] ValidFacilityTypes = { "Gym", "Pool", "Court", "Class" };
|
||||
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public FacilityService(IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_facilityRepository = facilityRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllFacilities(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive);
|
||||
|
||||
return facilities.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateFacility(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
if (!data.ContainsKey("facilityType")) throw new KeyNotFoundException("facilityType is required");
|
||||
|
||||
var facilityType = data["facilityType"].GetString() ?? "";
|
||||
if (!ValidFacilityTypes.Contains(facilityType))
|
||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var facility = new Facility
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
FacilityType = facilityType,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
DefaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1
|
||||
};
|
||||
|
||||
if (facility.DefaultCapacity < 1)
|
||||
throw new ArgumentException("defaultCapacity must be at least 1");
|
||||
|
||||
var result = await _facilityRepository.CreateFacility(facility);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateFacility(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
||||
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var existing = await _facilityRepository.GetFacilityById(facilityId)
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("facilityType"))
|
||||
{
|
||||
var facilityType = data["facilityType"].GetString() ?? existing.FacilityType;
|
||||
if (!ValidFacilityTypes.Contains(facilityType))
|
||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
||||
existing.FacilityType = facilityType;
|
||||
}
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("defaultCapacity"))
|
||||
{
|
||||
var capacity = data["defaultCapacity"].GetInt32();
|
||||
if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1");
|
||||
existing.DefaultCapacity = capacity;
|
||||
}
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
var result = await _facilityRepository.UpdateFacility(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetFacilityStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var isActive = data["isActive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facility = await _facilityRepository.SetFacilityActive(facilityId, clientId, isActive)
|
||||
?? throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(facility);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateFacilitySession(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
||||
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
|
||||
if (!data.ContainsKey("endTime")) throw new KeyNotFoundException("endTime is required");
|
||||
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||
if (facility == null || facility.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Facility not found");
|
||||
|
||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
||||
if (endTime <= startTime)
|
||||
throw new ArgumentException("endTime must be after startTime");
|
||||
|
||||
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
|
||||
? data["capacity"].GetInt32()
|
||||
: null;
|
||||
if (capacity.HasValue && capacity.Value < 1)
|
||||
throw new ArgumentException("capacity must be at least 1 when set");
|
||||
|
||||
var session = new FacilitySession
|
||||
{
|
||||
FKFacilityId = facilityId,
|
||||
FKClientId = clientId,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
Capacity = capacity
|
||||
};
|
||||
|
||||
var result = await _facilityRepository.CreateFacilitySession(session);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result, facility.Name, 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetSessions(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["facilityId"].GetString()!)
|
||||
: null;
|
||||
|
||||
var fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime()
|
||||
: DateTime.UtcNow.Date;
|
||||
var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime()
|
||||
: fromUtc.AddDays(7);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc);
|
||||
|
||||
var result = new List<FacilitySessionDto>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(session.FacilitySessionId);
|
||||
result.Add(MapToDto(session, session.Facility?.Name, bookedCount));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId)
|
||||
?? throw new KeyNotFoundException("Facility session not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(session);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static FacilityDto MapToDto(Facility facility) => new()
|
||||
{
|
||||
FacilityId = facility.FacilityId,
|
||||
Name = facility.Name,
|
||||
FacilityType = facility.FacilityType,
|
||||
Description = facility.Description,
|
||||
DefaultCapacity = facility.DefaultCapacity,
|
||||
IsActive = facility.IsActive,
|
||||
CreatedAt = facility.CreatedAt
|
||||
};
|
||||
|
||||
private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new()
|
||||
{
|
||||
FacilitySessionId = session.FacilitySessionId,
|
||||
FacilityId = session.FKFacilityId,
|
||||
FacilityName = facilityName ?? session.Facility?.Name,
|
||||
StartTime = session.StartTime,
|
||||
EndTime = session.EndTime,
|
||||
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
|
||||
BookedCount = bookedCount,
|
||||
IsCancelled = session.IsCancelled
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
|
||||
|
||||
|
||||
namespace Fitness_core_BE.Services.Feed
|
||||
{
|
||||
public class FeedService : IFeedService
|
||||
{
|
||||
// DocumentTypes.DocumentId for FEED_POST (seeded in CentralDbContext/AppDBContext).
|
||||
private const int FeedPostDocumentTypeId = 1005;
|
||||
|
||||
private readonly IFeedRepository _repository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IActivityLogService _activityLogService;
|
||||
private readonly IDocumentService _documentService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public FeedService(IFeedRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IDocumentService documentService, IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_repository = repository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
_activityLogService = activityLogService;
|
||||
_documentService = documentService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<object> GetFeedPostById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid postId = DataExtractor.GetRequiredGuid(data, "postId");
|
||||
|
||||
FeedPost post = await _repository.GetFeedPostByIdAsync(userId, postId);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public async Task<object> GetFeedForCustomer(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
int skip = data.ContainsKey("skip") && data["skip"].TryGetInt32(out var skipValue) ? skipValue : 0;
|
||||
int take = data.ContainsKey("take") && data["take"].TryGetInt32(out var takeValue) ? takeValue : 10;
|
||||
|
||||
// A customer with no active gym has no single client feed to scope to — the
|
||||
// repository already falls back to the platform-wide feed for them (clientId
|
||||
// stays null), so this no longer short-circuits to an empty list.
|
||||
var (clientId, feedList) =
|
||||
await _repository.GetFeedForCustomerAsync(userId, skip, take);
|
||||
|
||||
var feedListWithLikeCount = new List<object>();
|
||||
foreach (var feed in feedList)
|
||||
{
|
||||
var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId);
|
||||
feedListWithLikeCount.Add(new
|
||||
{
|
||||
feedPostId = feed.FeedPostId,
|
||||
caption = feed.Caption,
|
||||
description = feed.Description,
|
||||
userId = feed.FKUserId,
|
||||
docId = feed.FkDocId,
|
||||
isActive = feed.IsActive,
|
||||
createdAt = feed.CreatedAt,
|
||||
creator = feed.User.UserName,
|
||||
likeCount,
|
||||
clientId = clientId
|
||||
});
|
||||
}
|
||||
|
||||
return feedListWithLikeCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
//like
|
||||
public async Task<object> LikeAsync(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid postId = Guid.Parse(data["postId"].GetString()!);
|
||||
|
||||
|
||||
if (!await _repository.IsLikedAsync(postId, userId))
|
||||
{
|
||||
await _repository.AddLikeAsync(postId, userId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { success = true, message = "Post liked successfully" };
|
||||
}
|
||||
|
||||
return new { success = false, message = "Post already liked" };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//check like
|
||||
public async Task<object> IsLikedAsync(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid postId = Guid.Parse(data["postId"].GetString()!);
|
||||
bool isLiked = await _repository.IsLikedAsync(postId, userId);
|
||||
return new { isLiked };
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<object> UnlikeAsync(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid postId = Guid.Parse(data["postId"].GetString()!);
|
||||
Guid userId = Guid.Parse(data["userId"].GetString()!);
|
||||
|
||||
if (await _repository.IsLikedAsync(postId, userId))
|
||||
{
|
||||
await _repository.RemoveLikeAsync(postId, userId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { success = true, message = "Post unliked successfully" };
|
||||
}
|
||||
|
||||
return new { success = false, message = "Post not liked yet" };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//create feed
|
||||
public async Task<object> CreateFeed(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
// Tag every attachment with the FEED_POST document type unless the caller
|
||||
// already chose one. Must match a DocumentTypes row (UserDocuments FKs to it).
|
||||
object uploadPayload = payload;
|
||||
|
||||
if (data.TryGetValue("document", out var documentsElement) &&
|
||||
documentsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var payloadJson = System.Text.Json.JsonSerializer.Serialize(data);
|
||||
var payloadDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(
|
||||
payloadJson) ?? new Dictionary<string, object>();
|
||||
|
||||
var documentsArray = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(
|
||||
documentsElement.GetRawText()) ?? new List<Dictionary<string, object>>();
|
||||
|
||||
foreach (var document in documentsArray)
|
||||
{
|
||||
if (!document.ContainsKey("FileType"))
|
||||
{
|
||||
document["FileType"] = FeedPostDocumentTypeId;
|
||||
}
|
||||
}
|
||||
|
||||
payloadDict["document"] = documentsArray;
|
||||
uploadPayload = payloadDict;
|
||||
}
|
||||
|
||||
var docResponseObj = await _documentService.UploadDocument(uploadPayload, httpContext);
|
||||
|
||||
dynamic docResponse = docResponseObj;
|
||||
|
||||
if (docResponse == null || docResponse?.uploadResults == null || docResponse?.uploadResults.Count == 0)
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
return new { success = false, message = "Document upload failed" };
|
||||
}
|
||||
|
||||
var firstDoc = docResponse?.uploadResults[0];
|
||||
int docId = firstDoc?.DocumentId ?? 0;
|
||||
|
||||
var feedPost = new FeedPost
|
||||
{
|
||||
Caption = data.ContainsKey("caption") ? data["caption"].GetString() : null,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
FkDocId = docId,
|
||||
FKUserId = userId
|
||||
};
|
||||
|
||||
await _repository.CreateFeedAsync(feedPost);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var customerIds = await _repository.GetActiveCustomerIdsByPosterUserIdAsync(userId);
|
||||
if (customerIds.Count > 0)
|
||||
{
|
||||
var caption = feedPost.Caption;
|
||||
var body = string.IsNullOrWhiteSpace(caption)
|
||||
? "Your gym shared a new post. Tap to view."
|
||||
: caption!.Length > 120 ? caption[..120] + "…" : caption;
|
||||
|
||||
await _pushNotificationService.NotifyCustomersAsync(
|
||||
customerIds,
|
||||
"New post from your gym",
|
||||
body,
|
||||
"FeedPostCreated",
|
||||
"MainTabs",
|
||||
new { screen = "Connect" }
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}");
|
||||
}
|
||||
|
||||
return new { success = true, message = "Feed created successfully" };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> ShowFeedByClient(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
var feedList = await _repository.GetFeedByClientAsync(clientId);
|
||||
|
||||
if (feedList == null || !feedList.Any())
|
||||
{
|
||||
return new { success = false, message = "No feeds found", data = new List<object>() };
|
||||
}
|
||||
|
||||
var FeedListWithDoc = new List<object>();
|
||||
|
||||
foreach (var feed in feedList)
|
||||
{
|
||||
object? documentData = null;
|
||||
if (feed.FkDocId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var docPayload = new { documentId = feed.FkDocId.Value };
|
||||
documentData = await _documentService.GetDocumentById(docPayload, httpContext);
|
||||
}
|
||||
catch
|
||||
{
|
||||
documentData = null;
|
||||
}
|
||||
}
|
||||
|
||||
var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId);
|
||||
|
||||
FeedListWithDoc.Add(new
|
||||
{
|
||||
feedPostId = feed.FeedPostId,
|
||||
caption = feed.Caption,
|
||||
description = feed.Description,
|
||||
userId = feed.FKUserId,
|
||||
docId = feed.FkDocId,
|
||||
isActive = feed.IsActive,
|
||||
createdAt = feed.CreatedAt,
|
||||
likeCount = likeCount,
|
||||
document = documentData
|
||||
});
|
||||
}
|
||||
|
||||
return new { success = true, message = "Feeds retrieved successfully", data = FeedListWithDoc };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> DeleteFeedByPostId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid createUserId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid postId = DataExtractor.GetRequiredGuid(data, "postId");
|
||||
await _uow.BeginAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var result =await _repository.DeletePostPostIdAsync(createUserId, postId);
|
||||
await _uow.CommitAsync();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw new BadRequestException ("delete post failed") ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class AccessDeviceFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public AccessDeviceFunctionHandler(IAccessDeviceService accessDeviceService)
|
||||
{
|
||||
// Admin-auth actions only. The cloud no longer talks to devices directly:
|
||||
// - AddDevice / RemoveDevice / GeneratePairingCode are pure DB operations.
|
||||
// - AddUser / WhiteListMember / BlackListMember do their local DB work and
|
||||
// ENQUEUE a DeviceCommand for the on-prem agent to execute.
|
||||
// Device communication (attendance pull, command execution, enrollment)
|
||||
// is handled by the agent against the AgentApiKey-protected endpoints in AgentController.
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "AddDevice", (payload, _) => accessDeviceService.AddDevice(payload) },
|
||||
{ "RemoveDevice", (payload, _) => accessDeviceService.RemoveDevice(payload) },
|
||||
{ "GeneratePairingCode", (payload, _) => accessDeviceService.GeneratePairingCode(payload) },
|
||||
{ "AddUser", AddUser },
|
||||
{ "WhiteListMember", WhiteListMember },
|
||||
{ "BlackListMember", BlackListMember }
|
||||
};
|
||||
|
||||
Task<object> AddUser(object payload, HttpContext _)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var customerAccess = new CustomerAccess
|
||||
{
|
||||
Name = DataExtractor.GetRequiredString(data, "Name"),
|
||||
FKDeviceId = DataExtractor.GetRequiredGuid(data, "FKDeviceId"),
|
||||
FKCustomerId = DataExtractor.GetRequiredGuid(data, "FKCustomerId")
|
||||
};
|
||||
|
||||
return accessDeviceService.AddUser(customerAccess);
|
||||
}
|
||||
|
||||
Task<object> WhiteListMember(object payload, HttpContext _)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var customerId = DataExtractor.GetRequiredGuid(data, "customerId");
|
||||
return accessDeviceService.WhiteListMember(customerId).ContinueWith(t => (object)t.Result);
|
||||
}
|
||||
|
||||
Task<object> BlackListMember(object payload, HttpContext _)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var customerId = DataExtractor.GetRequiredGuid(data, "customerId");
|
||||
return accessDeviceService.BlackListMember(customerId).ContinueWith(t => (object)t.Result);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Fitness_core_BE.Services.ActivityLog;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class ActivityLogFuncHandler
|
||||
{
|
||||
private readonly IActivityLogService _activityLogService;
|
||||
|
||||
public ActivityLogFuncHandler(IActivityLogService activityLogService)
|
||||
{
|
||||
_activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
public async Task<object> ExecuteAsync(string functionName, object payload, HttpContext httpContext)
|
||||
{
|
||||
return functionName switch
|
||||
{
|
||||
"LogActivity" => await _activityLogService.LogActivity(payload, httpContext),
|
||||
"GetActivityLogs" => await _activityLogService.GetActivityLogs(payload, httpContext),
|
||||
_ => throw new NotImplementedException($"Function '{functionName}' is not implemented.")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class AttendanceFunctionHandler
|
||||
{
|
||||
private readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public AttendanceFunctionHandler(IAttendanceService attendanceService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetAllAttendanceRecords", attendanceService.GetAllAttendanceRecords },
|
||||
{ "GetAttendanceCounts", attendanceService.GetAttendanceCounts },
|
||||
{ "GetTodayAttendance", attendanceService.GetTodayAttendance }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Auth;
|
||||
using Fitness_core_BE.DTOs.Auth;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class AuthFuncHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<AuthResponse>>> _functionHandler;
|
||||
|
||||
public AuthFuncHandler(IAuthService authService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{"SendOtp", authService.SendOtp },
|
||||
{"VerifyOTP", authService.VerifyOTP },
|
||||
{"IsAvailable", authService.IsAvailable },
|
||||
{"RegisterUser", authService.RegisterUser },
|
||||
{"UpdateUser", authService.UpdateUser },
|
||||
{"GetUserDetails", authService.GetUserDetails },
|
||||
{"LoginUser", authService.LoginUser },
|
||||
{"RefreshToken", authService.RefreshToken },
|
||||
{"VerifyOtpForLogin", authService.VerifyOtpForLogin },
|
||||
{"forgotPassword", authService.ForgotPassword },
|
||||
{"resetPassword", authService.ResetPassword },
|
||||
{"resetPasswordWithToken", authService.ResetPasswordWithToken },
|
||||
{"LogOut", authService.LogOut }
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var authResponse = await handler(request.Payload, httpContext);
|
||||
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = authResponse.StatusCode,
|
||||
Message = authResponse.Message,
|
||||
Data = authResponse.Data
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
};
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = ex.Message,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class ChatFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, Task<object>>> _functionHandler;
|
||||
|
||||
public ChatFunctionHandler(IChatService chatService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetOrCreateChatAsync", chatService.GetOrCreateChatAsync },
|
||||
{ "GetClientById", chatService.SendMessageAsync },
|
||||
{ "RegisterClient", chatService.GetMessagesAsync }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class ClientFuncHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public ClientFuncHandler(IClientService clientService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetClientList", clientService.GetClientList },
|
||||
{ "GetClientById", clientService.GetClientById },
|
||||
{ "GetCustomerDetailsById", clientService.GetCustomerDetailsById },
|
||||
{ "CreateCustomerWithPlans", clientService.CreateCustomerWithPlans },
|
||||
{ "UpdateCustomerByClient", clientService.UpdateCustomerByClient },
|
||||
{"GetCustomersByClientId", clientService.GetCustomersByClientId},
|
||||
{"GetCustomerPlansByClientId", clientService.GetCustomerPlansByClientId},
|
||||
{"RegisterClient", clientService.RegisterClient},
|
||||
{ "GetPackageList", clientService.GetPackageList },
|
||||
{"GetAllCustomers", clientService.GetAllCustomers },
|
||||
{"DeleteCustomerByClient", clientService.DeleteCustomerByClient },
|
||||
{"ClientLogOut", clientService.ClientLogOut },
|
||||
{"GetAllCustomerPlans", clientService.GetAllCustomerPlans },
|
||||
{"GetMealPlansByClientId", clientService.GetMealPlansByClientId },
|
||||
{"GetWorkoutPlansByClientId", clientService.GetWorkoutPlansByClientId },
|
||||
{"GetMealPlanById", clientService.GetMealPlanById },
|
||||
{"GetWorkoutPlanById", clientService.GetWorkoutPlanById },
|
||||
{"GetCustomerPlanById", clientService.GetCustomerPlanById },
|
||||
{"GetOwnCards", clientService.GetCardDetails },
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.ClientInvoice;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class ClientInvoiceFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public ClientInvoiceFunctionHandler(IClientInvoiceService clientInvoiceService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateClientInvoiceFromSubscription", clientInvoiceService.CreateClientInvoiceFromSubscription },
|
||||
{ "GetClientInvoiceById", clientInvoiceService.GetClientInvoiceById },
|
||||
{ "GetClientInvoicesByClientId", clientInvoiceService.GetClientInvoicesByClientId },
|
||||
{ "GetClientInvoicesByUserId", clientInvoiceService.GetClientInvoicesByUserId },
|
||||
{ "UpdateClientInvoiceStatus", clientInvoiceService.UpdateClientInvoiceStatus },
|
||||
{ "CalculateClientInvoiceTotal", clientInvoiceService.CalculateClientInvoiceTotal }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class ClientStatFuncHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public ClientStatFuncHandler(IClientStatService clientStatService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetTotalCustomerCount", clientStatService.GetTotalCustomerCount },
|
||||
{ "GetMonthlyCustomerCount", clientStatService.GetMonthlyCustomerCount },
|
||||
{ "GetTotalWorkoutPlanCount", clientStatService.GetTotalWorkoutPlanCount },
|
||||
{ "GetTotalMealPlanCount", clientStatService.GetTotalMealPlanCount },
|
||||
{ "GetLastMonthCustomerCount", clientStatService.GetLastMonthCustomerCount },
|
||||
{ "GetLastFiveAttendances", clientStatService.GetLastFiveAttendances },
|
||||
{ "GetLastFivePayments", clientStatService.GetLastFivePayments },
|
||||
{ "GetLastMonthRevenue", clientStatService.GetLastMonthRevenue },
|
||||
{ "GetMonthlyRevenueVariation", (payload, ctx) => clientStatService.GetMonthlyRevenueVariation(payload, ctx) },
|
||||
{ "GetTotalRevenue", clientStatService.GetTotalRevenue },
|
||||
{ "GetClientStatSummary", (payload, ctx) => clientStatService.GetClientStatSummary(payload, ctx) }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object> { StatusCode = 401, Message = ex.Message, Data = null };
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object> { StatusCode = 404, Message = ex.Message, Data = null };
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object> { StatusCode = 400, Message = ex.Message, Data = null };
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object> { StatusCode = 403, Message = ex.Message, Data = null };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.CustomerService;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class CustomerFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public CustomerFunctionHandler(ICustomerService customerService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateCustomer", customerService.CreateCustomer },
|
||||
{ "DeleteCustomer", customerService.DeleteCustomer },
|
||||
{ "UpdateCustomer", customerService.UpdateCustomer },
|
||||
{ "GetCustomerMealPlan", customerService.GetCustomerMealPlan },
|
||||
{ "GetCustomerMealPlanById", customerService.GetCustomerMealPlanById },
|
||||
{ "GetCustomerWorkOutPlan", customerService.GetCustomerWorkOutPlan },
|
||||
{ "GetCustomerWorkoutPlanById", customerService.GetCustomerWorkoutPlanById },
|
||||
{ "FollowMealPlan", customerService.FollowMealPlan },
|
||||
{ "GetFollowedMealPlans", customerService.GetFollowedMealPlans },
|
||||
{ "GetFollowedWorkoutPlans", customerService.GetFollowedWorkoutPlans },
|
||||
{ "FollowWorkOutPlan", customerService.FollowWorkOutPlan },
|
||||
{ "GetClientDataById", customerService.GetClientDataById },
|
||||
{ "CreateCusHealthRec", customerService.CreateCusHealthRec },
|
||||
{ "CreateCusProgressRec", customerService.CreateCusProgressRec },
|
||||
{ "UpdateCusHealthRec", customerService.UpdateCusHealthRec },
|
||||
{ "UpdateCusProgressRec", customerService.UpdateCusProgressRec },
|
||||
{ "UpdateCustomerTargetNutritions", customerService.UpdateCustomerTargetNutritions },
|
||||
{ "GetClientGyms", customerService.GetClientGyms },
|
||||
{ "GetCustomerProgress", customerService.GetCustomerProgress },
|
||||
{ "UpdateWeeklyProgress", customerService.UpdateWeeklyProgress },
|
||||
{ "GetWeeklyProgressForCustomer", customerService.GetWeeklyProgressForCustomer },
|
||||
{ "GetCusBasicDetails", customerService.GetCusBasicDetails },
|
||||
{ "GetCusHealthDetails", customerService.GetCusHealthDetails },
|
||||
{ "GetCusProgressDetails", customerService.GetCusProgressDetails },
|
||||
{"CreateFeed", customerService.CreateFeed },
|
||||
{"GetOwnCards", customerService.GetCardDetails }
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.CustomerInvoice;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class CustomerInvoiceFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public CustomerInvoiceFunctionHandler(ICustomerInvoiceService customerInvoiceService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateCustomerInvoiceFromSubscription", customerInvoiceService.CreateCustomerInvoiceFromSubscription },
|
||||
{ "GetCustomerInvoiceById", customerInvoiceService.GetCustomerInvoiceById },
|
||||
{ "GetCustomerInvoicesByCustomerId", customerInvoiceService.GetCustomerInvoicesByCustomerId },
|
||||
{ "GetCustomerInvoicesByClientId", customerInvoiceService.GetCustomerInvoicesByClientId },
|
||||
{ "UpdateCustomerInvoiceStatus", customerInvoiceService.UpdateCustomerInvoiceStatus },
|
||||
{ "CalculateCustomerInvoiceTotal", customerInvoiceService.CalculateCustomerInvoiceTotal }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.CustomerPlanService;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class CustomerPlanFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public CustomerPlanFunctionHandler(ICustomerPlanService customerPlanService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetAllCustomerPlans", customerPlanService.GetAllCustomerPlans },
|
||||
{ "GetCustomerPlanById", customerPlanService.GetCustomerPlanById },
|
||||
{ "CreateCustomerPlan", customerPlanService.CreateCustomerPlan },
|
||||
{ "UpdateCustomerPlan", customerPlanService.UpdateCustomerPlan },
|
||||
{ "SetCustomerPlanStatus", customerPlanService.SetCustomerPlanStatus },
|
||||
{ "DeleteCustomerPlan", customerPlanService.DeleteCustomerPlan }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.CustomerSubscription;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class CustomerSubscriptionFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public CustomerSubscriptionFunctionHandler(ICustomerSubscriptionService customerSubscriptionService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateCustomerSubscription", customerSubscriptionService.CreateCustomerSubscription },
|
||||
{ "GetCustomerSubscriptionById", customerSubscriptionService.GetCustomerSubscriptionById },
|
||||
{ "GetCustomerSubscriptionsByCustomerId", customerSubscriptionService.GetCustomerSubscriptionsByCustomerId },
|
||||
{ "GetActiveSubscriptionByCustomerId", customerSubscriptionService.GetActiveSubscriptionByCustomerId }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Document;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class DocumentFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public DocumentFunctionHandler(IDocumentService documentService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "UploadDocument", documentService.UploadDocument },
|
||||
{ "GetDocumentsByUserId", documentService.GetDocumentsByUserId },
|
||||
{ "GetDocumentById", documentService.GetDocumentById },
|
||||
{ "RemoveDocument", documentService.RemoveDocument }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Feed;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class FeedFuncHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public FeedFuncHandler(IFeedService feedService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetFeedPostById", feedService.GetFeedPostById },
|
||||
{ "GetFeedList", feedService.GetFeedForCustomer },
|
||||
{ "Like", feedService.LikeAsync },
|
||||
{ "Unlike", feedService.UnlikeAsync },
|
||||
{ "IsLiked", feedService.IsLikedAsync },
|
||||
{ "CreateFeed", feedService.CreateFeed },
|
||||
{ "ShowFeedByClientId", feedService.ShowFeedByClient },
|
||||
{ "DeleteFeedByPostId", feedService.DeleteFeedByPostId }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (ForbiddenException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Meal;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class MealFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public MealFunctionHandler(IMealService mealService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetMealCategoryList", mealService.GetMealCategoryList },
|
||||
{ "CreateMealCategory", mealService.CreateMealCategory },
|
||||
{ "GetMealPlanList", mealService.GetMealPlanList },
|
||||
{ "GetMealPlanById", mealService.GetMealPlanById },
|
||||
{ "GetMealPlanByCustomerId", mealService.GetMealPlanByCustomerId },
|
||||
{ "CreateMealPlan", mealService.CreateMealPlan },
|
||||
{ "CreateCustomerMealPlan", mealService.CreateCustomerMealPlan },
|
||||
{ "UpdateMealPlan", mealService.UpdateMealPlan },
|
||||
{ "GetMealItemsByMealPlanId", mealService.GetMealItemsByMealPlanId },
|
||||
{ "CreateMealItem", mealService.CreateMealItem },
|
||||
{ "DeleteMealPlan", mealService.DeleteMealPlan }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class PaymentFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public PaymentFunctionHandler(IPaymentService paymentService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateCustomerPayment", paymentService.CreateCustomerPayment },
|
||||
{ "CreateClientPayment", paymentService.CreateClientPayment },
|
||||
{ "GetPaymentById", paymentService.GetPaymentById },
|
||||
{ "GetPaymentsByCustomerInvoice", paymentService.GetPaymentsByCustomerInvoice },
|
||||
{ "GetPaymentsByClientInvoice", paymentService.GetPaymentsByClientInvoice },
|
||||
{ "ApprovePayment", paymentService.ApprovePayment },
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (NotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (BadRequestException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Staff;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class StaffFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public StaffFunctionHandler(IStaffService staffService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetAllStaff", staffService.GetAllStaff },
|
||||
{ "GetStaffById", staffService.GetStaffById },
|
||||
{ "GetStaffByClientId", staffService.GetStaffByClientId },
|
||||
{ "CreateStaff", staffService.CreateStaff },
|
||||
{ "UpdateStaff", staffService.UpdateStaff },
|
||||
{ "DeleteStaff", staffService.DeleteStaff }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null!
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Ticket;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class TicketFuncHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public TicketFuncHandler(ITicketService ticketService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetTicketList", ticketService.GetTicketList },
|
||||
{ "GetTicketById", ticketService.GetTicketById },
|
||||
{ "GetTicketByUserId", ticketService.GetTicketByUserId },
|
||||
{ "GetTicketCategoryList", ticketService.GetTicketCategoryList },
|
||||
{ "CreateTicket", ticketService.CreateTicket },
|
||||
{ "UpdateTicket", ticketService.UpdateTicket },
|
||||
{ "SendMessage", ticketService.SendMessage },
|
||||
{ "SubmitTicketFeedback", ticketService.SubmitTicketFeedback },
|
||||
{ "CloseTicket", ticketService.CloseTicket },
|
||||
{ "DeleteTicket", ticketService.DeleteTicket }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class UserRegisterHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public UserRegisterHandler(ICustomerService customerService, IClientService clientService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "CreateCustomer", customerService.CreateCustomer },
|
||||
{"RegisterClient", clientService.RegisterClient }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Fitness_core_BE.Services.Workout;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services.FunctionHandler
|
||||
{
|
||||
public class WorkoutFunctionHandler
|
||||
{
|
||||
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
|
||||
|
||||
public WorkoutFunctionHandler(IWorkoutService workoutService)
|
||||
{
|
||||
_functionHandler = new()
|
||||
{
|
||||
{ "GetWorkoutCategoryList", workoutService.GetWorkoutCategoryList },
|
||||
{ "CreateWorkoutCategory", workoutService.CreateWorkoutCategory },
|
||||
{ "GetWorkoutPlanList", workoutService.GetWorkoutPlanList },
|
||||
{ "GetWorkoutPlanById", workoutService.GetWorkoutPlanById },
|
||||
{ "GetWorkoutPlanByCustomerId", workoutService.GetWorkoutPlanByCustomerId },
|
||||
{ "CreateWorkoutPlan", workoutService.CreateWorkoutPlan },
|
||||
{ "CreateCustomerWorkout", workoutService.CreateCustomerWorkout },
|
||||
{ "UpdateWorkoutPlan", workoutService.UpdateWorkoutPlan },
|
||||
{ "DeleteWorkoutPlan", workoutService.DeleteWorkoutPlan },
|
||||
{ "GetWorkoutItemsByWorkoutPlanId", workoutService.GetWorkoutItemsByWorkoutPlanId },
|
||||
{ "CreateWorkoutItem", workoutService.CreateWorkoutItem }
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
|
||||
{
|
||||
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 404,
|
||||
Message = $"Function '{request.FunctionName}' not found.",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
try
|
||||
{
|
||||
var results = await handler(request.Payload, httpContext);
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "success",
|
||||
Data = results
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = ex.Message,
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = $"An error occurred: {ex.Message}",
|
||||
Data = null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Fitness_core_BE.DTOs.Rbac;
|
||||
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 Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class GymRoleService : IGymRoleService
|
||||
{
|
||||
private readonly IGymRoleRepository _gymRoleRepository;
|
||||
private readonly INavItemRepository _navItemRepository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public GymRoleService(
|
||||
IGymRoleRepository gymRoleRepository,
|
||||
INavItemRepository navItemRepository,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_gymRoleRepository = gymRoleRepository;
|
||||
_navItemRepository = navItemRepository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<GymRoleDto>> GetRolesAsync(HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var roles = await _gymRoleRepository.GetRolesByClientId(clientId);
|
||||
return roles.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<GymRoleDto> CreateRoleAsync(CreateGymRoleRequest request, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var role = new GymRole { FKClientId = clientId, Name = request.Name };
|
||||
var created = await _gymRoleRepository.CreateRole(role);
|
||||
await _uow.SaveChangesAsync();
|
||||
return ToDto(created);
|
||||
}
|
||||
|
||||
public async Task<GymRoleDto> UpdateRoleAsync(Guid gymRoleId, UpdateGymRoleRequest request, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var role = await GetOwnedRole(gymRoleId, clientId);
|
||||
|
||||
if (request.Name != null) role.Name = request.Name;
|
||||
if (request.IsActive.HasValue) role.IsActive = request.IsActive.Value;
|
||||
|
||||
var updated = await _gymRoleRepository.UpdateRole(role);
|
||||
await _uow.SaveChangesAsync();
|
||||
return ToDto(updated);
|
||||
}
|
||||
|
||||
public async Task DeleteRoleAsync(Guid gymRoleId, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var role = await GetOwnedRole(gymRoleId, clientId);
|
||||
await _gymRoleRepository.DeleteRole(role);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<GymRoleDto> SetRolePermissionsAsync(Guid gymRoleId, SetRolePermissionsRequest request, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var role = await GetOwnedRole(gymRoleId, clientId);
|
||||
var requestedKeys = request.NavKeys?.Distinct().ToList() ?? new List<string>();
|
||||
var assignableKeys = (await _navItemRepository.GetActiveNavItemsOrdered())
|
||||
.Where(item => !item.IsOwnerOnly && item.Key != "dashboard")
|
||||
.Select(item => item.Key)
|
||||
.ToHashSet();
|
||||
var invalidKeys = requestedKeys.Where(key => !assignableKeys.Contains(key)).ToList();
|
||||
if (invalidKeys.Count > 0)
|
||||
throw new BadRequestException($"Invalid role permission: {string.Join(", ", invalidKeys)}.");
|
||||
|
||||
await _gymRoleRepository.ReplacePermissions(gymRoleId, requestedKeys);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var refreshed = await _gymRoleRepository.GetRoleById(gymRoleId)
|
||||
?? throw new NotFoundException("Role not found.");
|
||||
return ToDto(refreshed);
|
||||
}
|
||||
|
||||
public async Task AssignStaffRoleAsync(Guid staffId, AssignStaffRoleRequest request, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetOwnerClientId(httpContext);
|
||||
var assigned = await _gymRoleRepository.AssignStaffRole(staffId, clientId, request.GymRoleId);
|
||||
if (!assigned)
|
||||
throw new NotFoundException("Staff member or role not found for this gym.");
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Every public method funnels through here: only a caller who resolves to a Client
|
||||
// (the gym owner) can manage roles. Staff — regardless of any nav-section permission
|
||||
// they've been granted, including "staff" — are rejected before any GymRole/
|
||||
// GymRolePermission data is ever touched.
|
||||
private async Task<Guid> GetOwnerClientId(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
return await _userRoleHelper.GetOwnedClientId(userId)
|
||||
?? throw new UnauthorizedAccessException("Only the gym owner can manage roles and permissions.");
|
||||
}
|
||||
|
||||
private async Task<GymRole> GetOwnedRole(Guid gymRoleId, Guid clientId)
|
||||
{
|
||||
var role = await _gymRoleRepository.GetRoleById(gymRoleId)
|
||||
?? throw new NotFoundException("Role not found.");
|
||||
if (role.FKClientId != clientId)
|
||||
throw new NotFoundException("Role not found.");
|
||||
return role;
|
||||
}
|
||||
|
||||
private static GymRoleDto ToDto(GymRole role) => new()
|
||||
{
|
||||
GymRoleId = role.GymRoleId,
|
||||
Name = role.Name,
|
||||
IsActive = role.IsActive,
|
||||
NavKeys = role.Permissions?.Select(p => p.NavItemKey).ToList() ?? new List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public sealed record JobRunRecord(
|
||||
string JobName, Guid ClientId, DateTime RunAtUtc, string Status,
|
||||
int SubscriptionsRenewed, string? ErrorMessage);
|
||||
|
||||
// Persisted in CentralDbContext.JobRunLogs. Callers pass in whichever CentralDbContext they
|
||||
// already have open -- background jobs get theirs from TenantSchemaIterator (central is one
|
||||
// fixed database regardless of tenant, so any instance points at the same schema), the admin
|
||||
// controller gets a normal DI-injected one since it runs inside a real HTTP request.
|
||||
public interface IJobRunHistory
|
||||
{
|
||||
Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<JobRunRecord>> GetRecentAsync(CentralDbContext central, string jobName, int take, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class DbJobRunHistory : IJobRunHistory
|
||||
{
|
||||
public async Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default)
|
||||
{
|
||||
central.JobRunLogs.Add(new JobRunLog
|
||||
{
|
||||
JobRunLogId = Guid.NewGuid(),
|
||||
JobName = record.JobName,
|
||||
FKClientId = record.ClientId,
|
||||
RunAtUtc = record.RunAtUtc,
|
||||
Status = record.Status,
|
||||
SubscriptionsRenewed = record.SubscriptionsRenewed,
|
||||
ErrorMessage = record.ErrorMessage
|
||||
});
|
||||
await central.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<JobRunRecord>> GetRecentAsync(
|
||||
CentralDbContext central, string jobName, int take, CancellationToken ct = default)
|
||||
{
|
||||
return await central.JobRunLogs
|
||||
.AsNoTracking()
|
||||
.Where(r => r.JobName == jobName)
|
||||
.OrderByDescending(r => r.RunAtUtc)
|
||||
.Take(take)
|
||||
.Select(r => new JobRunRecord(r.JobName, r.FKClientId, r.RunAtUtc, r.Status, r.SubscriptionsRenewed, r.ErrorMessage))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
using Fitness_core_BE.Models;
|
||||
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.Exceptions;
|
||||
|
||||
namespace Fitness_core_BE.Services.Meal
|
||||
{
|
||||
public class MealService : IMealService
|
||||
{
|
||||
private readonly IMealRepository _repository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IActivityLogService _activityLogService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public MealService(IMealRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_repository = repository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
_activityLogService = activityLogService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
// Meal Category operations
|
||||
public async Task<object> GetMealCategoryList(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var categories = await _repository.GetMealCategoryListAsync();
|
||||
return categories;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateMealCategory(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
var mealCategory = new MealCategory
|
||||
{
|
||||
Name = data["name"].GetString()!,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealCategoryAsync(mealCategory);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(new { description = $"Meal category created with ID {mealCategory.MealCategoryId}" }, httpContext);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
return mealCategory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Meal Plan operations
|
||||
public async Task<object> GetMealPlanList(object payload, HttpContext httpContext)
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
string userType = await _userRoleHelper.GetUserType(userId);
|
||||
Guid clientId;
|
||||
if (userType == "Customer")
|
||||
{
|
||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||
try
|
||||
{
|
||||
clientId = await _userRoleHelper.GetClientIdByCusId(cusId);
|
||||
}
|
||||
catch (NotFoundException)
|
||||
{
|
||||
// Not every customer has joined a gym yet — they should still see the
|
||||
// platform-wide admin suggestions (FKClientId == null in the repo query).
|
||||
clientId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
clientId = await _userRoleHelper.GetClientId(userId);
|
||||
}
|
||||
try
|
||||
{
|
||||
var mealPlans = await _repository.GetMealPlanListAsync(userType, clientId);
|
||||
return mealPlans;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetMealPlanById(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
|
||||
|
||||
var mealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId);
|
||||
|
||||
if (mealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
|
||||
|
||||
// Parse MealItems JSON strings to objects in nested meal items
|
||||
if (mealPlan.MealItems != null)
|
||||
{
|
||||
foreach (var item in mealPlan.MealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mealPlan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new Exception("An error occurred while fetching the meal plan details.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetMealPlanByCustomerId(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
Guid customerId;
|
||||
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (data.ContainsKey("customerId") &&
|
||||
!string.IsNullOrEmpty(data["customerId"].GetString()))
|
||||
{
|
||||
customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
}
|
||||
else
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
customerId = await _userRoleHelper.GetCusId(userId);
|
||||
}
|
||||
|
||||
var mealPlans = await _repository.GetMealPlansByCustomerIdAsync(customerId);
|
||||
|
||||
|
||||
foreach (var mealPlan in mealPlans)
|
||||
{
|
||||
if (mealPlan.MealItems != null)
|
||||
{
|
||||
foreach (var item in mealPlan.MealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mealPlans;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new Exception("An error occurred while fetching meal plans for the customer.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateMealPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var newMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!);
|
||||
if (!await _repository.MealCategoryExistsAsync(newMealCategoryId))
|
||||
throw new NotFoundException("Meal category not found.");
|
||||
|
||||
// Create meal plan
|
||||
var mealPlan = new MealPlan
|
||||
{
|
||||
Name = data["name"].GetString()!,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(),
|
||||
FKMealCategoryId = newMealCategoryId,
|
||||
FKClientId = clientId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealPlanAsync(mealPlan);
|
||||
|
||||
// Handle customer assignments if provided
|
||||
if (data.ContainsKey("customerIds") && data["customerIds"].ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var customerIdsArray = data["customerIds"].EnumerateArray();
|
||||
foreach (var customerIdElement in customerIdsArray)
|
||||
{
|
||||
if (Guid.TryParse(customerIdElement.GetString(), out var customerId))
|
||||
{
|
||||
var customerMeal = new CustomerMeal
|
||||
{
|
||||
FKCustomerId = customerId,
|
||||
FKMealPlanId = mealPlan.MealPlanId,
|
||||
AssignedDate = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
await _repository.AssignMealToCustomerAsync(customerMeal);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward compatibility: single customerId field
|
||||
else if (data.ContainsKey("customerId") && !string.IsNullOrEmpty(data["customerId"].GetString()))
|
||||
{
|
||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
var customerMeal = new CustomerMeal
|
||||
{
|
||||
FKCustomerId = customerId,
|
||||
FKMealPlanId = mealPlan.MealPlanId,
|
||||
AssignedDate = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
await _repository.AssignMealToCustomerAsync(customerMeal);
|
||||
}
|
||||
|
||||
// Handle meal items if provided
|
||||
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var mealItemsArray = data["mealItems"].EnumerateArray();
|
||||
foreach (var mealItemData in mealItemsArray)
|
||||
{
|
||||
// Only create meal if name is provided
|
||||
if (mealItemData.TryGetProperty("name", out var nameElement) &&
|
||||
!string.IsNullOrEmpty(nameElement.GetString()))
|
||||
{
|
||||
// Parse nested mealItems JSON
|
||||
string? mealItemsJson = null;
|
||||
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
|
||||
{
|
||||
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
|
||||
}
|
||||
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
mealItemsJson = mealItemsElement.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
var meal = new Models.Meal
|
||||
{
|
||||
Name = nameElement.GetString()!,
|
||||
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
Calories = TryParseDecimal(mealItemData, "calories"),
|
||||
Protein = TryParseDecimal(mealItemData, "protein"),
|
||||
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
|
||||
Fats = TryParseDecimal(mealItemData, "fats"),
|
||||
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
|
||||
MealItems = mealItemsJson,
|
||||
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
|
||||
FKMealPlanId = mealPlan.MealPlanId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealAsync(meal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Meal plan created with ID {mealPlan.MealPlanId}" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
// Fetch and return the created meal plan with parsed JSON
|
||||
var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId);
|
||||
|
||||
if (createdMealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation");
|
||||
|
||||
// Parse MealItems JSON strings to objects in nested meal items
|
||||
if (createdMealPlan.MealItems != null)
|
||||
{
|
||||
foreach (var item in createdMealPlan.MealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdMealPlan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Plans created via the AI chat flow aren't given a category by the customer — the
|
||||
// mobile app used to guess one by fetching the category list and taking the first
|
||||
// entry, which was both an extra round-trip and an arbitrary pick. Categories are a
|
||||
// global taxonomy (MealCategory has no FKClientId), so a single well-known "AI
|
||||
// Generated" category is get-or-created here and reused for every gym's AI plans.
|
||||
private const string AiGeneratedCategoryName = "AI Generated";
|
||||
|
||||
private async Task<Guid> GetOrCreateAiMealCategoryIdAsync()
|
||||
{
|
||||
var categories = await _repository.GetMealCategoryListAsync();
|
||||
var existing = categories.FirstOrDefault(c =>
|
||||
string.Equals(c.Name, AiGeneratedCategoryName, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing != null)
|
||||
return existing.MealCategoryId;
|
||||
|
||||
var created = await _repository.CreateMealCategoryAsync(new MealCategory
|
||||
{
|
||||
MealCategoryId = Guid.NewGuid(),
|
||||
Name = AiGeneratedCategoryName,
|
||||
Description = "Plans generated by the AI meal planner",
|
||||
IsActive = true,
|
||||
CreateDate = DateTime.UtcNow
|
||||
});
|
||||
return created.MealCategoryId;
|
||||
}
|
||||
|
||||
public async Task<object> CreateCustomerMealPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid customerId;
|
||||
if (data.ContainsKey("customerId") &&
|
||||
!string.IsNullOrEmpty(data["customerId"].GetString()))
|
||||
{
|
||||
customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
}
|
||||
else
|
||||
{
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
customerId = await _userRoleHelper.GetCusId(userId);
|
||||
}
|
||||
|
||||
// The plan belongs to the customer's own gym, not a fixed placeholder client.
|
||||
Guid clientId = await _userRoleHelper.GetClientIdByCusId(customerId);
|
||||
|
||||
Guid mealCategoryId = data.ContainsKey("mealCategoryId") && !string.IsNullOrEmpty(data["mealCategoryId"].GetString())
|
||||
? Guid.Parse(data["mealCategoryId"].GetString()!)
|
||||
: await GetOrCreateAiMealCategoryIdAsync();
|
||||
|
||||
var mealPlan = new MealPlan
|
||||
{
|
||||
Name = data["name"].GetString()!,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(),
|
||||
FKMealCategoryId = mealCategoryId,
|
||||
FKClientId = clientId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealPlanAsync(mealPlan);
|
||||
|
||||
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var mealItemsArray = data["mealItems"].EnumerateArray();
|
||||
foreach (var mealItemData in mealItemsArray)
|
||||
{
|
||||
if (mealItemData.TryGetProperty("name", out var nameElement) &&
|
||||
!string.IsNullOrEmpty(nameElement.GetString()))
|
||||
{
|
||||
string? mealItemsJson = null;
|
||||
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
|
||||
{
|
||||
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
|
||||
}
|
||||
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
mealItemsJson = mealItemsElement.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
var meal = new Models.Meal
|
||||
{
|
||||
Name = nameElement.GetString()!,
|
||||
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
Calories = TryParseDecimal(mealItemData, "calories"),
|
||||
Protein = TryParseDecimal(mealItemData, "protein"),
|
||||
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
|
||||
Fats = TryParseDecimal(mealItemData, "fats"),
|
||||
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
|
||||
MealItems = mealItemsJson,
|
||||
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
|
||||
FKMealPlanId = mealPlan.MealPlanId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealAsync(meal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var customerMeal = new CustomerMeal
|
||||
{
|
||||
FKCustomerId = customerId,
|
||||
FKMealPlanId = mealPlan.MealPlanId,
|
||||
AssignedDate = DateTime.UtcNow,
|
||||
IsActive = true,
|
||||
IsFollowing = true
|
||||
};
|
||||
await _repository.AssignMealToCustomerAsync(customerMeal);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Customer meal plan created with ID {mealPlan.MealPlanId}" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId);
|
||||
|
||||
if (createdMealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation");
|
||||
|
||||
if (createdMealPlan.MealItems != null)
|
||||
{
|
||||
foreach (var item in createdMealPlan.MealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdMealPlan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Meal Item operations
|
||||
public async Task<object> GetMealItemsByMealPlanId(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
|
||||
|
||||
var mealItems = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
|
||||
|
||||
// Parse MealItems JSON strings to objects
|
||||
foreach (var item in mealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mealItems;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateMealItem(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
// Parse mealItems array and serialize to JSON string
|
||||
string? mealItemsJson = null;
|
||||
if (data.ContainsKey("mealItems"))
|
||||
{
|
||||
var mealItemsElement = data["mealItems"];
|
||||
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
|
||||
}
|
||||
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
mealItemsJson = mealItemsElement.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
var mealItem = new Models.Meal
|
||||
{
|
||||
Name = data["name"].GetString()!,
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
Calories = data.ContainsKey("calories") && !string.IsNullOrEmpty(data["calories"].GetString())
|
||||
? decimal.Parse(data["calories"].GetString()!)
|
||||
: (decimal?)null,
|
||||
Protein = data.ContainsKey("protein") && !string.IsNullOrEmpty(data["protein"].GetString())
|
||||
? decimal.Parse(data["protein"].GetString()!)
|
||||
: (decimal?)null,
|
||||
Carbohydrates = data.ContainsKey("carbohydrates") && !string.IsNullOrEmpty(data["carbohydrates"].GetString())
|
||||
? decimal.Parse(data["carbohydrates"].GetString()!)
|
||||
: (decimal?)null,
|
||||
Fats = data.ContainsKey("fats") && !string.IsNullOrEmpty(data["fats"].GetString())
|
||||
? decimal.Parse(data["fats"].GetString()!)
|
||||
: (decimal?)null,
|
||||
ServingSize = data.ContainsKey("servingSize") ? data["servingSize"].GetString() : null,
|
||||
MealItems = mealItemsJson,
|
||||
PreparationNotes = data.ContainsKey("preparationNotes") ? data["preparationNotes"].GetString() : null,
|
||||
FKMealPlanId = Guid.Parse(data["mealPlanId"].GetString()!),
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealAsync(mealItem);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// Log activity - non-blocking, failures won't crash the operation
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(new { description = $"Meal item created with ID {mealItem.MealId}" }, httpContext);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
// Parse mealItems JSON string to object before returning
|
||||
object? parsedMealItems = mealItem.MealItems;
|
||||
if (!string.IsNullOrEmpty(mealItem.MealItems))
|
||||
{
|
||||
try
|
||||
{
|
||||
parsedMealItems = System.Text.Json.JsonSerializer.Deserialize<object>(mealItem.MealItems);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
mealItem.MealId,
|
||||
mealItem.Name,
|
||||
mealItem.Description,
|
||||
mealItem.Calories,
|
||||
mealItem.Protein,
|
||||
mealItem.Carbohydrates,
|
||||
mealItem.Fats,
|
||||
mealItem.ServingSize,
|
||||
MealItems = parsedMealItems,
|
||||
mealItem.PreparationNotes,
|
||||
FKMealPlanId = mealItem.FKMealPlanId,
|
||||
mealItem.IsActive,
|
||||
mealItem.CreateDate
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Meal Plan
|
||||
public async Task<object> UpdateMealPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
|
||||
|
||||
var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
|
||||
if (existingMealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
|
||||
|
||||
// Update meal plan properties
|
||||
if (data.ContainsKey("name") && !string.IsNullOrEmpty(data["name"].GetString()))
|
||||
existingMealPlan.Name = data["name"].GetString()!;
|
||||
|
||||
if (data.ContainsKey("description"))
|
||||
existingMealPlan.Description = data["description"].GetString();
|
||||
|
||||
if (data.ContainsKey("isTemplate"))
|
||||
existingMealPlan.IsTemplate = data["isTemplate"].GetBoolean();
|
||||
|
||||
if (data.ContainsKey("mealCategoryId"))
|
||||
existingMealPlan.FKMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!);
|
||||
|
||||
await _repository.UpdateMealPlanAsync(existingMealPlan);
|
||||
|
||||
// Handle meal items update if provided
|
||||
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
// Get existing meal items for this plan
|
||||
var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
|
||||
|
||||
// Delete all existing meals (clean slate approach)
|
||||
foreach (var existingMeal in existingMeals)
|
||||
{
|
||||
await _repository.DeleteMealAsync(existingMeal.MealId);
|
||||
}
|
||||
|
||||
// Create new meals from payload
|
||||
var mealItemsArray = data["mealItems"].EnumerateArray();
|
||||
foreach (var mealItemData in mealItemsArray)
|
||||
{
|
||||
// Only create meal if name is provided
|
||||
if (mealItemData.TryGetProperty("name", out var nameElement) &&
|
||||
!string.IsNullOrEmpty(nameElement.GetString()))
|
||||
{
|
||||
string? mealItemsJson = null;
|
||||
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
|
||||
{
|
||||
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
|
||||
}
|
||||
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
mealItemsJson = mealItemsElement.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
var meal = new Models.Meal
|
||||
{
|
||||
Name = nameElement.GetString()!,
|
||||
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
Calories = TryParseDecimal(mealItemData, "calories"),
|
||||
Protein = TryParseDecimal(mealItemData, "protein"),
|
||||
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
|
||||
Fats = TryParseDecimal(mealItemData, "fats"),
|
||||
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
|
||||
MealItems = mealItemsJson,
|
||||
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
|
||||
FKMealPlanId = mealPlanId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateMealAsync(meal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Meal plan updated with ID {mealPlanId}" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
var updatedMealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId);
|
||||
|
||||
if (updatedMealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found after update");
|
||||
|
||||
// Parse MealItems JSON strings to objects in nested meal items
|
||||
if (updatedMealPlan.MealItems != null)
|
||||
{
|
||||
foreach (var item in updatedMealPlan.MealItems)
|
||||
{
|
||||
var itemAsString = item.MealItems as string;
|
||||
if (!string.IsNullOrEmpty(itemAsString))
|
||||
{
|
||||
try
|
||||
{
|
||||
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep as string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMealPlan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Meal Plan
|
||||
public async Task<object> DeleteMealPlan(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
|
||||
|
||||
var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
|
||||
if (existingMealPlan == null)
|
||||
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
|
||||
|
||||
// Delete all associated meal items first (foreign key constraint)
|
||||
var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
|
||||
foreach (var meal in existingMeals)
|
||||
{
|
||||
await _repository.DeleteMealAsync(meal.MealId);
|
||||
}
|
||||
|
||||
// Delete the meal plan
|
||||
await _repository.DeleteMealPlanAsync(mealPlanId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Meal plan deleted with ID {mealPlanId}" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Meal plan with ID {mealPlanId} has been deleted successfully"
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method
|
||||
private decimal? TryParseDecimal(System.Text.Json.JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop))
|
||||
{
|
||||
if (prop.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
{
|
||||
return prop.GetDecimal();
|
||||
}
|
||||
else if (prop.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
var strValue = prop.GetString();
|
||||
if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper for JsonElement
|
||||
private decimal? TryParseDecimal(Dictionary<string, System.Text.Json.JsonElement> data, string key)
|
||||
{
|
||||
if (!data.ContainsKey(key))
|
||||
return null;
|
||||
|
||||
var element = data[key];
|
||||
if (element.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
return element.GetDecimal();
|
||||
|
||||
if (element.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
var strValue = element.GetString();
|
||||
if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result))
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Customer Meal Assignment operations
|
||||
public async Task<object> AssignMealToCustomer(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
|
||||
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||
|
||||
var customerMeal = new CustomerMeal
|
||||
{
|
||||
FKCustomerId = customerId,
|
||||
FKMealPlanId = mealPlanId,
|
||||
AssignedDate = DateTime.UtcNow,
|
||||
Notes = data.ContainsKey("notes") ? data["notes"].GetString() : null,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await _repository.AssignMealToCustomerAsync(customerMeal);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Meal plan {mealPlanId} assigned to customer {customerId}" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
|
||||
var planName = plan?.Name ?? "a meal plan";
|
||||
await _pushNotificationService.NotifyCustomerAsync(
|
||||
customerId,
|
||||
"New meal plan assigned",
|
||||
$"Your coach assigned you \"{planName}\". Tap to view.",
|
||||
"MealPlanAssigned",
|
||||
"MealPlanSuggestionDetail",
|
||||
new { mealPlanId }
|
||||
);
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
customerMeal.CustomerMealId,
|
||||
customerMeal.FKCustomerId,
|
||||
customerMeal.FKMealPlanId,
|
||||
customerMeal.AssignedDate,
|
||||
customerMeal.Notes,
|
||||
customerMeal.IsActive
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UnassignMealFromCustomer(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid customerMealId = Guid.Parse(data["customerMealId"].GetString()!);
|
||||
|
||||
var result = await _repository.UnassignMealFromCustomerAsync(customerMealId);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
throw new KeyNotFoundException($"Customer meal assignment with ID {customerMealId} not found");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(
|
||||
new { description = $"Meal assignment {customerMealId} removed" },
|
||||
httpContext
|
||||
);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
return new { success = true, message = "Meal unassigned from customer successfully" };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Fitness_core_BE.DTOs.Rbac;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class NavigationService : INavigationService
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly INavItemRepository _navItemRepository;
|
||||
|
||||
public NavigationService(
|
||||
IPermissionService permissionService,
|
||||
INavItemRepository navItemRepository)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_navItemRepository = navItemRepository;
|
||||
}
|
||||
|
||||
public async Task<NavResponseDto> GetNavigationAsync(HttpContext httpContext)
|
||||
{
|
||||
var permissionContext =
|
||||
await _permissionService.GetPermissionContextAsync(httpContext);
|
||||
var allItems = await _navItemRepository.GetActiveNavItemsOrdered();
|
||||
|
||||
var visibleItems = allItems
|
||||
.Where(item =>
|
||||
item.Key == "dashboard" ||
|
||||
permissionContext.IsOwner ||
|
||||
permissionContext.AllowedNavKeys.Contains(item.Key))
|
||||
.Where(item => !item.IsOwnerOnly || permissionContext.IsOwner)
|
||||
.Select(item => new NavItemDto
|
||||
{
|
||||
Key = item.Key,
|
||||
Label = item.Label,
|
||||
ShortLabel = item.ShortLabel,
|
||||
Href = item.Href,
|
||||
Icon = item.Icon
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new NavResponseDto
|
||||
{
|
||||
IsOwner = permissionContext.IsOwner,
|
||||
Items = visibleItems
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Fitness_core_BE.Infra.Tenancy;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Periodically locks (and un-locks) member fingerprint access based on each client's
|
||||
/// configurable Client.LockAfterDaysOverdue setting (req 7). Reuses the existing
|
||||
/// AccessDeviceService.LockCustomer/UnlockCustomer (BlackListMember/WhiteListMember)
|
||||
/// pipeline — this job only decides *who* should be locked, not *how*.
|
||||
///
|
||||
/// This is a background service with no HttpContext, so TenantConnectionResolver's normal
|
||||
/// per-request tenant resolution can never apply here -- a DI-scoped TenantDbContext always
|
||||
/// resolves to the same single fallback schema regardless of which client is being checked.
|
||||
/// Every client's overdue-check therefore opens its OWN schema directly via
|
||||
/// TenantSchemaIterator instead of using the scope's injected TenantDbContext.
|
||||
///
|
||||
/// The per-client algorithm itself lives in OverdueLockRunner so
|
||||
/// AdminBackgroundJobsController's manual "trigger now" endpoint can call the exact same
|
||||
/// code path instead of duplicating it -- this class only owns the 6-hour loop and the
|
||||
/// central-only client lookup.
|
||||
/// </summary>
|
||||
public class OverdueLockJob : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(6);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<OverdueLockJob> _logger;
|
||||
|
||||
public OverdueLockJob(IServiceScopeFactory scopeFactory, ILogger<OverdueLockJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OverdueLockJob run failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var iterator = scope.ServiceProvider.GetRequiredService<TenantSchemaIterator>();
|
||||
var runner = scope.ServiceProvider.GetRequiredService<OverdueLockRunner>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Central-only lookup -- safe to use the scope's normal DI-resolved repository,
|
||||
// since GetClientsWithOverdueLockPolicyAsync only ever touches CentralDbContext.
|
||||
var bootstrapRepo = scope.ServiceProvider.GetRequiredService<IOverdueLockRepository>();
|
||||
var clients = await bootstrapRepo.GetClientsWithOverdueLockPolicyAsync(ct);
|
||||
|
||||
foreach (var client in clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await runner.RunForClientAsync(client, now, iterator, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OverdueLockJob failed for client {ClientId}.", client.ClientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Fitness_core_BE.Infra.Tenancy;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Repos;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
// The per-client overdue-lock algorithm, extracted verbatim from OverdueLockJob so the
|
||||
// admin "trigger now" endpoint (AdminBackgroundJobsController) can call the exact same code
|
||||
// path instead of duplicating it. Pure extraction -- no change to the locking logic itself.
|
||||
// Mirrors the SubscriptionRenewalRunner/SubscriptionRenewalJob split.
|
||||
public class OverdueLockRunner(ILogger<OverdueLockRunner> logger)
|
||||
{
|
||||
private readonly ILogger<OverdueLockRunner> _logger = logger;
|
||||
|
||||
public async Task RunForClientAsync(Client client, DateTime now, TenantSchemaIterator iterator, CancellationToken ct)
|
||||
{
|
||||
await using var schemaCtx = await iterator.OpenSchemaForClientAsync(client.ClientId, ct);
|
||||
if (schemaCtx == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OverdueLockJob: client {ClientId} has a lock policy but no provisioned tenant schema; skipping.",
|
||||
client.ClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var repository = new OverdueLockRepository(schemaCtx.Tenant, schemaCtx.Central);
|
||||
var accessDeviceService = new AccessDeviceService(
|
||||
new EFUnitOfWork(schemaCtx.Central, schemaCtx.Tenant),
|
||||
new AccessDeviceRepo(schemaCtx.Tenant, schemaCtx.Central),
|
||||
new AttendanceRepository(schemaCtx.Tenant),
|
||||
new DeviceCommandRepo(schemaCtx.Tenant),
|
||||
new PairingCodeRepo(schemaCtx.Central));
|
||||
|
||||
var effectiveDays = client.LockAfterDaysOverdue ?? Client.DefaultLockAfterDaysOverdue;
|
||||
var thresholdDate = now.AddDays(-effectiveDays);
|
||||
|
||||
var overdueCustomerIds = await repository.GetOverdueCustomerIdsAsync(
|
||||
client.ClientId, thresholdDate, ct);
|
||||
|
||||
foreach (var customerId in overdueCustomerIds)
|
||||
{
|
||||
var customer = await repository.GetCustomerByIdAsync(customerId, ct);
|
||||
if (customer == null)
|
||||
continue;
|
||||
var alreadyLocked = await repository.IsCustomerAccessDisabledAsync(
|
||||
client.ClientId, customer.FKUserId, ct);
|
||||
if (alreadyLocked)
|
||||
continue;
|
||||
|
||||
await accessDeviceService.SetCustomerAccessAsync(
|
||||
client.ClientId, customerId, enabled: false, reason: "Overdue");
|
||||
_logger.LogInformation("Auto-locked customer {CustomerId} for client {ClientId} (overdue).", customerId, client.ClientId);
|
||||
}
|
||||
|
||||
// Auto-unlock members who were locked for being overdue but are no longer
|
||||
// (paid, or their overdue invoice fell out of scope) — never touches manual locks.
|
||||
var lockedOverdueCustomerIds =
|
||||
await repository.GetCustomersLockedForOverdueAsync(client.ClientId, ct);
|
||||
|
||||
foreach (var customerId in lockedOverdueCustomerIds.Except(overdueCustomerIds))
|
||||
{
|
||||
await accessDeviceService.SetCustomerAccessAsync(
|
||||
client.ClientId, customerId, enabled: true, reason: null);
|
||||
_logger.LogInformation("Auto-unlocked customer {CustomerId} for client {ClientId} (no longer overdue).", customerId, client.ClientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.DTOs.PaymentGateways;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PaymentGatewayClient : IPaymentGatewayClient
|
||||
{
|
||||
private readonly IClientRepository _clientRepository;
|
||||
private readonly IPaymentServiceClient _paymentServiceClient;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public PaymentGatewayClient(
|
||||
IClientRepository clientRepository,
|
||||
IPaymentServiceClient paymentServiceClient,
|
||||
IUnitOfWork uow,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_clientRepository = clientRepository;
|
||||
_paymentServiceClient = paymentServiceClient;
|
||||
_uow = uow;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/');
|
||||
}
|
||||
|
||||
public async Task<OnboardCardResult> OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/cards/onboard", request, ct);
|
||||
return Deserialize<OnboardCardResult>(body);
|
||||
}
|
||||
|
||||
public async Task<CardDetailsResult> GetCardDetailsAsync(Guid clientId, string cardToken, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Get, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
|
||||
return Deserialize<CardDetailsResult>(body);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<CardDetailsResult>> ListCardsAsync(Guid clientId, string customerId, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/cards", null, ct,
|
||||
query: $"customerId={Uri.EscapeDataString(customerId)}");
|
||||
return Deserialize<List<CardDetailsResult>>(body);
|
||||
}
|
||||
|
||||
public async Task DeactivateCardAsync(Guid clientId, string cardToken, CancellationToken ct = default)
|
||||
{
|
||||
await SendAsync(clientId, HttpMethod.Delete, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
|
||||
}
|
||||
|
||||
public async Task<SetDefaultCardResult> SetDefaultCardAsync(Guid clientId, string cardToken, string customerId, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Patch, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}/default",
|
||||
new { customerId }, ct);
|
||||
return Deserialize<SetDefaultCardResult>(body);
|
||||
}
|
||||
|
||||
public async Task<OnboardCardResult> GetOnboardingStatusAsync(Guid clientId, string gatewayName, string sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Get,
|
||||
$"/api/v1/cards/onboarding/{Uri.EscapeDataString(gatewayName)}/{Uri.EscapeDataString(sessionId)}", null, ct);
|
||||
return Deserialize<OnboardCardResult>(body);
|
||||
}
|
||||
|
||||
public async Task<AttemptPaymentResult> AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/payments/attempt", request, ct);
|
||||
return Deserialize<AttemptPaymentResult>(body);
|
||||
}
|
||||
|
||||
public async Task<RefundPaymentResult> RefundPaymentAsync(Guid clientId, Guid paymentId, bool isVoid, CancellationToken ct = default)
|
||||
{
|
||||
var body = await SendAsync(clientId, HttpMethod.Post, $"/api/v1/refunds/{paymentId}", new { isVoid }, ct);
|
||||
return Deserialize<RefundPaymentResult>(body);
|
||||
}
|
||||
|
||||
public async Task<PaymentListResult> ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var queryParts = new List<string> { $"page={page}", $"pageSize={pageSize}" };
|
||||
if (!string.IsNullOrWhiteSpace(cardToken))
|
||||
{
|
||||
queryParts.Add($"cardToken={Uri.EscapeDataString(cardToken)}");
|
||||
}
|
||||
|
||||
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/payments", null, ct, query: string.Join("&", queryParts));
|
||||
return Deserialize<PaymentListResult>(body);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(string body)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(body, JsonOptions)
|
||||
?? throw new ServiceUnavailableException("Payment-Service returned an empty response.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mints this client's Payment-Service Platform the first time it's needed, storing the
|
||||
/// ApiKey/HmacSecret on Client since Payment-Service only ever returns them once.
|
||||
/// </summary>
|
||||
private async Task<(string ApiKey, string HmacSecret)> EnsureCredentialsAsync(Guid clientId, CancellationToken ct)
|
||||
{
|
||||
var client = await _clientRepository.GetClientEntityByIdAsync(clientId, ct)
|
||||
?? throw new NotFoundException($"Client {clientId} not found.");
|
||||
|
||||
if (string.IsNullOrEmpty(client.ClientPaymentApiKey) || string.IsNullOrEmpty(client.ClientPaymentHmacSecret))
|
||||
{
|
||||
var (platformId, apiKey, hmacSecret) = await _paymentServiceClient.ProvisionPlatformAsync(
|
||||
clientId, client.ClientName ?? "Gym", ct);
|
||||
|
||||
client.FKPaymentPlatformId = platformId;
|
||||
client.ClientPaymentApiKey = apiKey;
|
||||
client.ClientPaymentHmacSecret = hmacSecret;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return (client.ClientPaymentApiKey!, client.ClientPaymentHmacSecret!);
|
||||
}
|
||||
|
||||
private async Task<string> SendAsync(Guid clientId, HttpMethod method, string path, object? body, CancellationToken ct, string? query = null)
|
||||
{
|
||||
var (apiKey, hmacSecret) = await EnsureCredentialsAsync(clientId, ct);
|
||||
|
||||
var bodyJson = body is null ? string.Empty : JsonSerializer.Serialize(body);
|
||||
var timestamp = DateTimeOffset.UtcNow.ToString("o");
|
||||
// Signed over the path only — Payment-Service's HmacValidationMiddleware signs
|
||||
// against Request.Path.Value, which never includes the query string.
|
||||
var signature = ComputeSignature(hmacSecret, method.Method, path, timestamp, bodyJson);
|
||||
|
||||
var url = string.IsNullOrEmpty(query) ? $"{_baseUrl}{path}" : $"{_baseUrl}{path}?{query}";
|
||||
using var httpRequest = new HttpRequestMessage(method, url);
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
httpRequest.Headers.Add("X-Api-Key", apiKey);
|
||||
httpRequest.Headers.Add("X-Timestamp", timestamp);
|
||||
httpRequest.Headers.Add("X-Signature", signature);
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
httpRequest.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.SendAsync(httpRequest, ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ServiceUnavailableException("Could not reach Payment-Service.", ex);
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {responseBody}");
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
// Must match Payment-Service's Middleware/HmacValidationMiddleware.ComputeSignature
|
||||
// exactly: METHOD\nPATH\nTIMESTAMP\nBODY, HMACSHA256, hex lowercase.
|
||||
private static string ComputeSignature(string secret, string method, string path, string timestamp, string body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var payload = $"{method}\n{path}\n{timestamp}\n{body}";
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
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.Enums;
|
||||
|
||||
namespace Fitness_core_BE.Services.Payment
|
||||
{
|
||||
public class PaymentService : IPaymentService
|
||||
{
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
|
||||
private readonly IClientInvoiceRepository _clientInvoiceRepository;
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IReceiptService _receiptService;
|
||||
private readonly IThirdPartyService _thirdPartyService;
|
||||
private readonly IAccessDeviceService _accessDeviceService;
|
||||
private readonly InvoicePaymentTokenHelper _invoicePaymentTokenHelper;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PaymentService(
|
||||
IPaymentRepository paymentRepository,
|
||||
ICustomerInvoiceRepository customerInvoiceRepository,
|
||||
IClientInvoiceRepository clientInvoiceRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IReceiptService receiptService,
|
||||
IThirdPartyService thirdPartyService,
|
||||
IAccessDeviceService accessDeviceService,
|
||||
InvoicePaymentTokenHelper invoicePaymentTokenHelper,
|
||||
IConfiguration config,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_paymentRepository = paymentRepository;
|
||||
_customerInvoiceRepository = customerInvoiceRepository;
|
||||
_clientInvoiceRepository = clientInvoiceRepository;
|
||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_receiptService = receiptService;
|
||||
_thirdPartyService = thirdPartyService;
|
||||
_accessDeviceService = accessDeviceService;
|
||||
_invoicePaymentTokenHelper = invoicePaymentTokenHelper;
|
||||
_config = config;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<object> CreateCustomerPayment(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId");
|
||||
decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount");
|
||||
|
||||
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
|
||||
?? throw new NotFoundException($"Customer invoice {customerInvoiceId} not found");
|
||||
|
||||
Guid? cardId = null;
|
||||
var cardIdStr = DataExtractor.GetOptionalString(data, "cardId");
|
||||
if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId))
|
||||
cardId = parsedCardId;
|
||||
|
||||
var attempt = new PaymentTransaction
|
||||
{
|
||||
PayDate = DateTime.UtcNow,
|
||||
PayStatus = "Pending",
|
||||
PayAmount = payAmount,
|
||||
PayNote = DataExtractor.GetOptionalString(data, "payNote"),
|
||||
FK_CardId = cardId,
|
||||
FK_CustomerInvoiceId = customerInvoiceId,
|
||||
FK_ClientId = invoice.FKClientId,
|
||||
FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId,
|
||||
BeneficiaryType = BeneficiaryType.Customer,
|
||||
RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice",
|
||||
BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"),
|
||||
PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"),
|
||||
IsActive = true,
|
||||
IsApproved = false
|
||||
};
|
||||
|
||||
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { success = true, message = "Payment attempt created", payment = result };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateClientPayment(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId");
|
||||
decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount");
|
||||
|
||||
var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId)
|
||||
?? throw new NotFoundException($"Client invoice {clientInvoiceId} not found");
|
||||
|
||||
Guid? cardId = null;
|
||||
var cardIdStr = DataExtractor.GetOptionalString(data, "cardId");
|
||||
if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId))
|
||||
cardId = parsedCardId;
|
||||
|
||||
var attempt = new PaymentTransaction
|
||||
{
|
||||
PayDate = DateTime.UtcNow,
|
||||
PayStatus = "Pending",
|
||||
PayAmount = payAmount,
|
||||
PayNote = DataExtractor.GetOptionalString(data, "payNote"),
|
||||
FK_CardId = cardId,
|
||||
FK_ClientInvoiceId = clientInvoiceId,
|
||||
FK_ClientId = invoice.ClientSubscription?.FKClientId,
|
||||
BeneficiaryType = BeneficiaryType.Client,
|
||||
RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice",
|
||||
BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"),
|
||||
PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"),
|
||||
IsActive = true,
|
||||
IsApproved = false
|
||||
};
|
||||
|
||||
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new { success = true, message = "Payment attempt created", payment = result };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetPaymentById(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId");
|
||||
|
||||
var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId)
|
||||
?? throw new NotFoundException($"Payment {attemptId} not found");
|
||||
|
||||
return payment;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetPaymentsByCustomerInvoice(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId");
|
||||
|
||||
var payments = await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(customerInvoiceId);
|
||||
return payments;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetPaymentsByClientInvoice(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId");
|
||||
|
||||
var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId);
|
||||
return payments;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> ApprovePayment(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId");
|
||||
|
||||
var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId)
|
||||
?? throw new NotFoundException($"Payment {attemptId} not found");
|
||||
|
||||
payment.IsApproved = true;
|
||||
payment.PayStatus = "Approved";
|
||||
|
||||
await _paymentRepository.UpdatePaymentAttemptAsync(payment);
|
||||
|
||||
if (payment.FK_CustomerInvoiceId.HasValue)
|
||||
{
|
||||
// Roll the invoice status up from the approved total (Pending/Partial/Paid)
|
||||
// and re-allocate the schedule, rather than blindly marking "Paid".
|
||||
await _uow.SaveChangesAsync();
|
||||
await RollupCustomerInvoiceAsync(payment.FK_CustomerInvoiceId.Value);
|
||||
}
|
||||
else if (payment.FK_ClientInvoiceId.HasValue)
|
||||
{
|
||||
await _clientInvoiceRepository.UpdateClientInvoiceStatus(payment.FK_ClientInvoiceId.Value, "Paid");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
if (payment.FK_ClientInvoiceId.HasValue)
|
||||
{
|
||||
// Post-commit, best-effort: email/SMS the gym owner a receipt PDF for their
|
||||
// platform-fee payment. Never let a notification failure affect the approval.
|
||||
try
|
||||
{
|
||||
var receipt = await _receiptService.BuildForClientInvoiceAsync(payment.FK_ClientInvoiceId.Value, payment.PayAmount);
|
||||
await _receiptService.SendReceiptAsync(receipt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Client invoice receipt notification failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new { success = true, message = "Payment approved", payment };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner records a counter payment against a subscription. Immediately approved.
|
||||
/// Fills the earliest unpaid installments; non-split plans must be settled in full.
|
||||
/// </summary>
|
||||
public async Task<object> RecordSubscriptionPayment(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId");
|
||||
decimal amount = DataExtractor.GetRequiredDecimal(data, "amount");
|
||||
string? note = DataExtractor.GetOptionalString(data, "note");
|
||||
string paymentMethod = DataExtractor.GetRequiredString(data, "paymentMethod");
|
||||
string? bankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo");
|
||||
|
||||
if (paymentMethod != "Cash" && paymentMethod != "Bank")
|
||||
throw new ArgumentException("paymentMethod must be 'Cash' or 'Bank'");
|
||||
|
||||
if (amount <= 0)
|
||||
throw new ArgumentException("Payment amount must be greater than zero");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId)
|
||||
?? throw new NotFoundException("Subscription not found");
|
||||
|
||||
var plan = subscription.CustomerPlan
|
||||
?? throw new NotFoundException("Subscription plan not found");
|
||||
|
||||
// Authorization: the subscription's plan must belong to the caller's gym.
|
||||
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (plan.FKClientId != clientId)
|
||||
throw new UnauthorizedException("This subscription does not belong to your gym");
|
||||
|
||||
var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId);
|
||||
if (invoice == null)
|
||||
{
|
||||
// GetInvoiceBySubscriptionIdAsync also returns null while a renewal invoice
|
||||
// is awaiting approval (its only invoices are unapproved and/or RolledOver
|
||||
// right now) -- that is not a legacy subscription, and self-healing here
|
||||
// would silently create a duplicate, uncarried-forward invoice. Only
|
||||
// self-heal a genuine "never had an invoice" subscription.
|
||||
if (await _customerInvoiceRepository.HasPendingApprovalInvoiceAsync(subscriptionId))
|
||||
throw new BadRequestException(
|
||||
"This member's renewal is awaiting approval. Approve, change plan, or reject it before recording a payment.");
|
||||
|
||||
// Self-heal legacy subscriptions that predate automatic invoicing.
|
||||
invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId);
|
||||
await _uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
|
||||
var balance = invoice.Amount - alreadyPaid;
|
||||
|
||||
if (amount > balance)
|
||||
throw new ArgumentException($"Amount exceeds the outstanding balance ({balance:0.##})");
|
||||
if (!plan.AllowSplitPayments && amount != balance)
|
||||
throw new ArgumentException("This plan must be paid in full; split payments are not enabled");
|
||||
|
||||
var attempt = new PaymentTransaction
|
||||
{
|
||||
PayDate = DateTime.UtcNow,
|
||||
PayStatus = "Approved",
|
||||
PayAmount = amount,
|
||||
PayNote = note,
|
||||
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
|
||||
FK_ClientId = invoice.FKClientId,
|
||||
FK_CustomerId = subscription.FKCustomerId,
|
||||
BeneficiaryType = BeneficiaryType.Customer,
|
||||
RefType = "manual",
|
||||
PaymentMethod = paymentMethod,
|
||||
BankRefNo = paymentMethod == "Bank" ? bankRefNo : null,
|
||||
IsActive = true,
|
||||
IsApproved = true
|
||||
};
|
||||
|
||||
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// Post-commit, best-effort: text the member and email them a receipt PDF.
|
||||
// Never let a notification failure affect the recorded payment.
|
||||
try
|
||||
{
|
||||
var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, amount);
|
||||
await _receiptService.SendReceiptAsync(receipt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}");
|
||||
}
|
||||
|
||||
// Post-commit, best-effort: once the invoice is fully settled, undo whatever
|
||||
// overdue lock put the member's fingerprint access on hold -- the owner just
|
||||
// collected the money, they shouldn't also have to remember to hit the separate
|
||||
// unlock endpoint. SetCustomerAccessAsync already carries this onto every
|
||||
// co-member on a shared group/couple subscription, not just the payer.
|
||||
if (summary.Status == "Paid")
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await _customerRepository.GetCustomerById(subscription.FKCustomerId);
|
||||
if (customer != null)
|
||||
{
|
||||
var accessStatus = await _accessDeviceService.GetAccessStatusAsync(clientId, customer.FKUserId);
|
||||
if (accessStatus?.DesiredAccessState == "Disabled")
|
||||
await _accessDeviceService.SetCustomerAccessAsync(clientId, subscription.FKCustomerId, enabled: true, reason: null);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Auto-unlock after payment failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
invoiceId = invoice.CustomerInvoiceId,
|
||||
invoiceAmount = summary.InvoiceAmount,
|
||||
paidAmount = summary.Paid,
|
||||
balance = summary.Balance,
|
||||
status = summary.Status,
|
||||
payment = result
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetSubscriptionPayments(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId)
|
||||
?? throw new NotFoundException("Subscription not found");
|
||||
|
||||
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId)
|
||||
throw new UnauthorizedException("This subscription does not belong to your gym");
|
||||
|
||||
var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId);
|
||||
decimal invoiceAmount = invoice?.Amount ??
|
||||
subscription.CustomerPlan.PlanPrice +
|
||||
(subscription.CustomerPlan.HasOnboardingFee ? subscription.CustomerPlan.OnboardingFeeAmount : 0m);
|
||||
var payments = invoice != null
|
||||
? await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(invoice.CustomerInvoiceId)
|
||||
: new List<PaymentTransaction>();
|
||||
var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(subscriptionId);
|
||||
|
||||
decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount);
|
||||
|
||||
return new
|
||||
{
|
||||
invoiceId = invoice?.CustomerInvoiceId,
|
||||
invoiceAmount,
|
||||
paidAmount = paid,
|
||||
balance = invoiceAmount - paid,
|
||||
status = paid >= invoiceAmount && invoiceAmount > 0 ? "Paid" : paid > 0 ? "Partial" : "Pending",
|
||||
installments = installments.Select(i => new
|
||||
{
|
||||
i.SubscriptionInstallmentId,
|
||||
i.InstallmentNo,
|
||||
i.DueDate,
|
||||
i.Amount,
|
||||
i.PaidAmount,
|
||||
i.Status
|
||||
}),
|
||||
payments = payments.Select(p => new
|
||||
{
|
||||
p.AttemptId,
|
||||
p.PayDate,
|
||||
p.PayStatus,
|
||||
p.PayAmount,
|
||||
p.PayNote,
|
||||
p.RefType,
|
||||
p.PaymentMethod,
|
||||
p.BankRefNo,
|
||||
p.IsApproved
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner-triggered "Charge now" against the member's consented auto-charge card.
|
||||
/// No live payment gateway is wired up anywhere in this backend yet (see
|
||||
/// ActivationService's own deferred platform-billing charge) — this records the attempt
|
||||
/// and leaves it Pending, exactly like the rest of the app defers real settlement. Replace
|
||||
/// the body of this method with a real PayHere/Stripe charge call once a gateway exists;
|
||||
/// until then the owner falls back to SendPaymentLink or RecordSubscriptionPayment.
|
||||
/// </summary>
|
||||
public async Task<object> AttemptAutoCharge(Guid customerInvoiceId, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
|
||||
?? throw new NotFoundException("Invoice not found");
|
||||
|
||||
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (invoice.FKClientId != clientId)
|
||||
throw new UnauthorizedException("This invoice does not belong to your gym");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId)
|
||||
?? throw new NotFoundException("Subscription not found");
|
||||
|
||||
if (!subscription.AutoChargeEnabled || subscription.FKAutoChargeCardId == null)
|
||||
throw new BadRequestException("Auto-deduct isn't enabled for this member. Send a payment link or mark as paid instead.");
|
||||
|
||||
var card = await _customerRepository.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value);
|
||||
if (card == null || !card.ConsentGiven)
|
||||
throw new BadRequestException("This member hasn't consented to auto-deduct. Send a payment link or mark as paid instead.");
|
||||
|
||||
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
|
||||
var balance = invoice.Amount - alreadyPaid;
|
||||
if (balance <= 0)
|
||||
throw new BadRequestException("This invoice is already fully paid.");
|
||||
|
||||
var attempt = new PaymentTransaction
|
||||
{
|
||||
PayDate = DateTime.UtcNow,
|
||||
PayStatus = "Pending",
|
||||
PayAmount = balance,
|
||||
FK_CardId = card.JobId,
|
||||
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
|
||||
FK_ClientId = invoice.FKClientId,
|
||||
FK_CustomerId = subscription.FKCustomerId,
|
||||
BeneficiaryType = BeneficiaryType.Customer,
|
||||
RefType = "auto",
|
||||
IsActive = true,
|
||||
IsApproved = false
|
||||
};
|
||||
|
||||
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
status = "Pending",
|
||||
message = "Charge attempt recorded — pending confirmation.",
|
||||
payment = result
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emails/texts the member a link to the public pay-by-link page so they can pay this
|
||||
/// invoice online themselves — the other fallback alongside AttemptAutoCharge/RecordSubscriptionPayment.
|
||||
/// </summary>
|
||||
public async Task<object> SendPaymentLink(Guid customerInvoiceId, HttpContext httpContext)
|
||||
{
|
||||
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var model = await _receiptService.BuildForInvoiceAsync(customerInvoiceId, requireClientId: clientId);
|
||||
|
||||
if (model.Balance <= 0)
|
||||
throw new BadRequestException("This invoice is already fully paid.");
|
||||
if (string.IsNullOrWhiteSpace(model.MemberEmail) && string.IsNullOrWhiteSpace(model.MemberPhone))
|
||||
throw new BadRequestException("This member has no phone or email on file to send the link to.");
|
||||
|
||||
string token = _invoicePaymentTokenHelper.Generate(customerInvoiceId);
|
||||
string baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/');
|
||||
string link = $"{baseUrl}/pay/{token}";
|
||||
|
||||
bool emailSent = false;
|
||||
bool smsSent = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.MemberEmail))
|
||||
{
|
||||
try
|
||||
{
|
||||
string html =
|
||||
$"<p>Hi {model.MemberName},</p>" +
|
||||
$"<p>You have an outstanding balance of <b>{model.Currency} {model.Balance:N2}</b> for <b>{model.PlanName}</b> at {model.GymName}.</p>" +
|
||||
$"<p><a href=\"{link}\">Pay now</a></p>" +
|
||||
$"<p>— {model.GymName}<br/><small>powered by GYM OS</small></p>";
|
||||
await _thirdPartyService.SendEmailAsync(model.MemberEmail!, $"Payment link — {model.GymName}", html);
|
||||
emailSent = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Payment link email failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.MemberPhone))
|
||||
{
|
||||
try
|
||||
{
|
||||
string sms = $"Pay {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}: {link}";
|
||||
var smsResult = await _thirdPartyService.SendSmsAsync(model.MemberPhone!, message: sms);
|
||||
smsSent = smsResult.StatusCode is >= 200 and < 300;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Payment link SMS failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!emailSent && !smsSent)
|
||||
throw new BadRequestException("Couldn't send the payment link by email or SMS. Check that notifications are configured.");
|
||||
|
||||
string via = emailSent && smsSent ? "email and SMS" : emailSent ? "email" : "SMS";
|
||||
return new { success = true, message = $"Payment link sent via {via}" };
|
||||
}
|
||||
|
||||
public async Task<object> GetInvoiceByPaymentToken(string token)
|
||||
{
|
||||
var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token)
|
||||
?? throw new NotFoundException("This payment link is invalid or has expired.");
|
||||
|
||||
var model = await _receiptService.BuildForInvoiceAsync(invoiceId);
|
||||
|
||||
return new
|
||||
{
|
||||
memberName = model.MemberName,
|
||||
gymName = model.GymName,
|
||||
planName = model.PlanName,
|
||||
amount = model.InvoiceAmount,
|
||||
balance = model.Balance,
|
||||
currency = model.Currency
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public, one-time self-serve payment from the pay-by-link page. Since the member is
|
||||
/// completing this live (not a background auto-charge), it's approved immediately — same
|
||||
/// treatment as RecordSubscriptionPayment. cardToken is a mock value today; this is the
|
||||
/// seam where a real gateway checkout replaces the stub later.
|
||||
/// </summary>
|
||||
public async Task<object> ChargeInvoiceByPaymentToken(string token, object payload)
|
||||
{
|
||||
var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token)
|
||||
?? throw new NotFoundException("This payment link is invalid or has expired.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
DataExtractor.GetRequiredString(data, "cardToken");
|
||||
|
||||
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(invoiceId)
|
||||
?? throw new NotFoundException("Invoice not found");
|
||||
|
||||
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
|
||||
var balance = invoice.Amount - alreadyPaid;
|
||||
if (balance <= 0)
|
||||
throw new BadRequestException("This invoice is already fully paid.");
|
||||
|
||||
var attempt = new PaymentTransaction
|
||||
{
|
||||
PayDate = DateTime.UtcNow,
|
||||
PayStatus = "Approved",
|
||||
PayAmount = balance,
|
||||
PayNote = "Paid online via payment link",
|
||||
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
|
||||
FK_ClientId = invoice.FKClientId,
|
||||
FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId,
|
||||
BeneficiaryType = BeneficiaryType.Customer,
|
||||
RefType = "link",
|
||||
IsActive = true,
|
||||
IsApproved = true
|
||||
};
|
||||
|
||||
await _paymentRepository.CreatePaymentAttemptAsync(attempt);
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, balance);
|
||||
await _receiptService.SendReceiptAsync(receipt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
invoiceId = invoice.CustomerInvoiceId,
|
||||
paidAmount = summary.Paid,
|
||||
balance = summary.Balance,
|
||||
status = summary.Status
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes an invoice's status from its approved payment total and
|
||||
/// re-allocates the installment schedule (idempotent — always derived from the total).
|
||||
/// </summary>
|
||||
private async Task<(decimal InvoiceAmount, decimal Paid, decimal Balance, string Status)> RollupCustomerInvoiceAsync(Guid customerInvoiceId)
|
||||
{
|
||||
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
|
||||
?? throw new NotFoundException("Invoice not found");
|
||||
|
||||
decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId);
|
||||
string status = paid >= invoice.Amount && invoice.Amount > 0
|
||||
? "Paid"
|
||||
: paid > 0 ? "Partial" : "Pending";
|
||||
|
||||
await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status);
|
||||
|
||||
// Distribute the total across installments in order.
|
||||
var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(invoice.FKCustomerSubscriptionId);
|
||||
decimal remaining = paid;
|
||||
foreach (var installment in installments)
|
||||
{
|
||||
decimal fill = Math.Min(remaining, installment.Amount);
|
||||
installment.PaidAmount = fill;
|
||||
installment.Status = fill >= installment.Amount
|
||||
? "Paid"
|
||||
: fill > 0 ? "Partial" : "Pending";
|
||||
remaining -= fill;
|
||||
}
|
||||
|
||||
return (invoice.Amount, paid, invoice.Amount - paid, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.DTOs.PaymentGateways;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PaymentServiceClient : IPaymentServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _internalKey;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public PaymentServiceClient(IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/');
|
||||
_internalKey = configuration["PaymentService:InternalApiKey"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<List<GatewayDefinitionDto>> ListGatewaysAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await SendAsync(HttpMethod.Get, "/api/v1/gateways", null, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonSerializer.Deserialize<List<GatewayDefinitionDto>>(body, JsonOptions) ?? new List<GatewayDefinitionDto>();
|
||||
}
|
||||
|
||||
public async Task<GatewayDefinitionDto> UpsertGatewayAsync(UpsertGatewayDefinitionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await SendAsync(HttpMethod.Post, "/api/v1/gateways", request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonSerializer.Deserialize<GatewayDefinitionDto>(body, JsonOptions)
|
||||
?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response.");
|
||||
}
|
||||
|
||||
public async Task<GatewayDefinitionDto> SetGatewayEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await SendAsync(HttpMethod.Put, $"/api/v1/gateways/{id}/enabled", new { isEnabled }, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonSerializer.Deserialize<GatewayDefinitionDto>(body, JsonOptions)
|
||||
?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response.");
|
||||
}
|
||||
|
||||
public async Task UpsertPlatformCredentialAsync(UpsertPlatformGatewayCredentialRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await SendAsync(
|
||||
HttpMethod.Put,
|
||||
$"/api/v1/gateways/platforms/{request.PlatformId}/credentials/{request.GatewayName}",
|
||||
new { credentialsJson = request.CredentialsJson },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(Guid PlatformId, string ApiKey, string HmacSecret)> ProvisionPlatformAsync(
|
||||
Guid clientId, string displayName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await SendAsync(
|
||||
HttpMethod.Post,
|
||||
"/api/v1/platforms",
|
||||
new { externalClientId = clientId, displayName },
|
||||
cancellationToken);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var platformId = doc.RootElement.GetProperty("platformId").GetGuid();
|
||||
var apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? string.Empty;
|
||||
var hmacSecret = doc.RootElement.GetProperty("hmacSecret").GetString() ?? string.Empty;
|
||||
|
||||
return (platformId, apiKey, hmacSecret);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{_baseUrl}{path}");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("X-Internal-Key", _internalKey);
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ServiceUnavailableException("Could not reach Payment-Service.", ex);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {errorBody}");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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.DTOs.PaymentsEnablement;
|
||||
using Fitness_core_BE.DTOs.PaymentGateways;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Models.Enums;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PaymentsEnablementService : IPaymentsEnablementService
|
||||
{
|
||||
private readonly IPaymentsEnablementRepository _paymentsRepository;
|
||||
private readonly IClientRepository _clientRepository;
|
||||
private readonly IPaymentServiceClient _paymentServiceClient;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PaymentsEnablementService(
|
||||
IPaymentsEnablementRepository paymentsRepository,
|
||||
IClientRepository clientRepository,
|
||||
IPaymentServiceClient paymentServiceClient,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_paymentsRepository = paymentsRepository;
|
||||
_clientRepository = clientRepository;
|
||||
_paymentServiceClient = paymentServiceClient;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public Task<List<PaymentTrackOption>> GetTrackOptionsAsync()
|
||||
{
|
||||
return Task.FromResult(new List<PaymentTrackOption>
|
||||
{
|
||||
new(PaymentTrack.OwnGateway.ToString(), new { description = "Use your own PayHere/Stripe merchant account.", feePercentage = 0 }),
|
||||
new(PaymentTrack.HexaFitness.ToString(), new { description = "Use HexaFitness's payment gateway; requires KYC before first payout.", feePercentage = 2.5 })
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<PaymentTrackSelectionResult> SelectTrackAsync(HttpContext httpContext, SelectPaymentTrackRequest request)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
if (!Enum.TryParse<PaymentTrack>(request.Track, true, out var track))
|
||||
throw new BadRequestException($"Unknown payment track '{request.Track}'.");
|
||||
|
||||
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
|
||||
?? throw new NotFoundException("Client not found.");
|
||||
|
||||
client.PaymentTrack = track;
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
return new PaymentTrackSelectionResult(track.ToString(), track == PaymentTrack.HexaFitness);
|
||||
}
|
||||
|
||||
public async Task<List<GatewayDefinitionDto>> GetAvailableGatewaysAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return (await _paymentServiceClient.ListGatewaysAsync(ct))
|
||||
.Where(gateway => gateway.IsEnabled)
|
||||
.OrderByDescending(gateway => gateway.IsDefault)
|
||||
.ThenBy(gateway => gateway.DisplayName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ClientPayoutKycDto> SubmitKycAsync(
|
||||
HttpContext httpContext,
|
||||
SubmitGatewayOnboardingRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var gateway = (await GetAvailableGatewaysAsync(ct))
|
||||
.FirstOrDefault(item => item.Id == request.GatewayId)
|
||||
?? throw new BadRequestException("Select an available payment gateway.");
|
||||
|
||||
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
|
||||
?? throw new NotFoundException("Client not found.");
|
||||
|
||||
var current = await _paymentsRepository.GetKycByClientIdAsync(clientId, ct);
|
||||
if (current?.KycStatus == KycStatus.Pending)
|
||||
throw new BadRequestException("A gateway KYC request is already pending review.");
|
||||
if (current?.KycStatus == KycStatus.Approved)
|
||||
throw new BadRequestException("Gateway KYC is already approved for this client.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
// Catalog gateways are platform-managed, so this follows the
|
||||
// HexaFitness payment track rather than client-owned credentials.
|
||||
client.PaymentTrack = PaymentTrack.HexaFitness;
|
||||
|
||||
var kyc = new ClientPayoutKyc
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
SelectedGatewayId = gateway.Id,
|
||||
SelectedGatewayName = gateway.DisplayName,
|
||||
KycStatus = KycStatus.Pending,
|
||||
SubmittedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _paymentsRepository.CreateKycAsync(kyc, ct);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return ToDto(kyc, client);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayOnboardingStatusDto> GetKycStatusAsync(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId);
|
||||
return kyc is null
|
||||
? new GatewayOnboardingStatusDto("NotSubmitted", null, null, null)
|
||||
: new GatewayOnboardingStatusDto(
|
||||
kyc.KycStatus.ToString(),
|
||||
kyc.SelectedGatewayId,
|
||||
kyc.SelectedGatewayName,
|
||||
kyc.RejectionReason);
|
||||
}
|
||||
|
||||
public async Task<WalletDto> GetWalletAsync(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId)
|
||||
?? throw new NotFoundException("Wallet not found for this client.");
|
||||
|
||||
var ledger = await _paymentsRepository.GetLedgerAsync(wallet.Id);
|
||||
|
||||
return new WalletDto(
|
||||
wallet.Balance,
|
||||
wallet.Currency,
|
||||
ledger.Select(e => new WalletLedgerEntryDto(e.Id, e.EntryType.ToString(), e.Amount, e.Description, e.CreatedAt)).ToList(),
|
||||
wallet.LastPayoutDate);
|
||||
}
|
||||
|
||||
public async Task<PayoutScheduleDto> GetPayoutScheduleAsync(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId)
|
||||
?? throw new NotFoundException("Wallet not found for this client.");
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var daysUntilTuesday = ((int)DayOfWeek.Tuesday - (int)today.DayOfWeek + 7) % 7;
|
||||
daysUntilTuesday = daysUntilTuesday == 0 ? 7 : daysUntilTuesday;
|
||||
var nextPayoutDate = today.AddDays(daysUntilTuesday);
|
||||
|
||||
return new PayoutScheduleDto(nextPayoutDate, "Weekly (Tuesday)", wallet.Balance);
|
||||
}
|
||||
|
||||
|
||||
// NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record;
|
||||
// that flow no longer exists for a single already-real club (see PayoutKycReviewService's
|
||||
// ToDto for the same note) -- always null here now.
|
||||
private static ClientPayoutKycDto ToDto(
|
||||
ClientPayoutKyc kyc,
|
||||
Client client)
|
||||
=> new(
|
||||
kyc.Id,
|
||||
kyc.FKClientId,
|
||||
client.ClientName,
|
||||
kyc.SelectedGatewayId,
|
||||
kyc.SelectedGatewayName,
|
||||
kyc.KycStatus.ToString(),
|
||||
kyc.SubmittedAt,
|
||||
kyc.ReviewedAt,
|
||||
kyc.RejectionReason,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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.DTOs.PaymentsEnablement;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.Enums;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PayoutKycReviewService : IPayoutKycReviewService
|
||||
{
|
||||
private readonly IPaymentsEnablementRepository _paymentsRepository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PayoutKycReviewService(IPaymentsEnablementRepository paymentsRepository, UserRoleHelper userRoleHelper, IUnitOfWork uow)
|
||||
{
|
||||
_paymentsRepository = paymentsRepository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<ClientPayoutKycDto>> ListAsync(string? status)
|
||||
{
|
||||
var (items, _) = await _paymentsRepository.ListKycAsync(null);
|
||||
var latestPerClient = items
|
||||
.GroupBy(item => item.FKClientId)
|
||||
.Select(group => group
|
||||
.OrderByDescending(item => item.SubmittedAt)
|
||||
.ThenByDescending(item => item.Id)
|
||||
.First());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
if (!Enum.TryParse<KycStatus>(status, true, out var parsedStatus))
|
||||
throw new BadRequestException($"Unknown KYC status '{status}'.");
|
||||
latestPerClient = latestPerClient
|
||||
.Where(item => item.KycStatus == parsedStatus);
|
||||
}
|
||||
|
||||
return latestPerClient
|
||||
.OrderByDescending(item => item.SubmittedAt)
|
||||
.Select(ToDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ClientPayoutKycDto> GetByClientIdAsync(Guid clientId)
|
||||
{
|
||||
var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId)
|
||||
?? throw new NotFoundException("No KYC submission found for this client.");
|
||||
return ToDto(kyc);
|
||||
}
|
||||
|
||||
public async Task<ClientPayoutKycDto> ReviewAsync(Guid clientId, ReviewPayoutKycRequest request, HttpContext httpContext)
|
||||
{
|
||||
var kyc = await _paymentsRepository.GetKycForUpdateAsync(clientId)
|
||||
?? throw new NotFoundException("No KYC submission found for this client.");
|
||||
|
||||
if (!Enum.TryParse<KycStatus>(request.Status, true, out var newStatus))
|
||||
throw new BadRequestException($"Unknown KYC status '{request.Status}'.");
|
||||
if (newStatus is not KycStatus.Approved and not KycStatus.Rejected)
|
||||
throw new BadRequestException("KYC can only be approved or rejected.");
|
||||
if (kyc.KycStatus != KycStatus.Pending)
|
||||
throw new BadRequestException("Only pending KYC submissions can be reviewed.");
|
||||
if (newStatus == KycStatus.Rejected && string.IsNullOrWhiteSpace(request.Reason))
|
||||
throw new BadRequestException("A rejection reason is required.");
|
||||
|
||||
var adminUserId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var adminId = await _userRoleHelper.GetAdminId(adminUserId)
|
||||
?? throw new ForbiddenException("Only admins may review payout KYC.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
kyc.KycStatus = newStatus;
|
||||
kyc.ReviewedAt = DateTime.UtcNow;
|
||||
kyc.ReviewedByAdminId = adminId;
|
||||
kyc.RejectionReason = newStatus == KycStatus.Rejected
|
||||
? request.Reason!.Trim()
|
||||
: null;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return ToDto(kyc);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record
|
||||
// (collected during signup); that flow no longer exists for a single already-real club,
|
||||
// so these are always null here now. If gateway KYC needs NIC info going forward, collect
|
||||
// it directly on the KYC submission instead of reintroducing that dependency.
|
||||
private static ClientPayoutKycDto ToDto(Models.ClientPayoutKyc kyc)
|
||||
{
|
||||
return new ClientPayoutKycDto(
|
||||
kyc.Id,
|
||||
kyc.FKClientId,
|
||||
kyc.Client?.ClientName,
|
||||
kyc.SelectedGatewayId,
|
||||
kyc.SelectedGatewayName,
|
||||
kyc.KycStatus.ToString(),
|
||||
kyc.SubmittedAt,
|
||||
kyc.ReviewedAt,
|
||||
kyc.RejectionReason,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IStaffRepository _staffRepository;
|
||||
private readonly IGymRoleRepository _gymRoleRepository;
|
||||
private readonly INavItemRepository _navItemRepository;
|
||||
|
||||
public PermissionService(
|
||||
UserRoleHelper userRoleHelper,
|
||||
IStaffRepository staffRepository,
|
||||
IGymRoleRepository gymRoleRepository,
|
||||
INavItemRepository navItemRepository)
|
||||
{
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_staffRepository = staffRepository;
|
||||
_gymRoleRepository = gymRoleRepository;
|
||||
_navItemRepository = navItemRepository;
|
||||
}
|
||||
|
||||
public async Task<PermissionContext> GetPermissionContextAsync(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
// Owner check first: the gym owner always has full access and never touches
|
||||
// GymRole/GymRolePermission at all.
|
||||
var clientId = await _userRoleHelper.GetOwnedClientId(userId);
|
||||
|
||||
if (clientId.HasValue)
|
||||
{
|
||||
var allKeys = (await _navItemRepository.GetActiveNavItemsOrdered())
|
||||
.Select(n => n.Key)
|
||||
.ToHashSet();
|
||||
return new PermissionContext(IsOwner: true, ClientId: clientId, StaffId: null, AllowedNavKeys: allKeys);
|
||||
}
|
||||
|
||||
var staffId = await _userRoleHelper.GetStaffId(userId);
|
||||
if (staffId.HasValue)
|
||||
{
|
||||
var staff = await _staffRepository.GetStaffById(staffId.Value);
|
||||
if (staff?.FKGymRoleId is Guid gymRoleId && staff.GymRole?.IsActive == true)
|
||||
{
|
||||
var keys = (await _gymRoleRepository.GetPermissionKeys(gymRoleId)).ToHashSet();
|
||||
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: keys);
|
||||
}
|
||||
|
||||
// Staff with no role assigned yet: dashboard only, not zero -- so forgetting
|
||||
// to assign a role (the create-staff flow makes it optional) leaves someone
|
||||
// with a blank-but-working dashboard instead of a fully broken login.
|
||||
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: new HashSet<string> { "dashboard" });
|
||||
}
|
||||
|
||||
// Neither owner nor staff (e.g. Customer/Admin token hitting a client-scoped endpoint).
|
||||
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: null, AllowedNavKeys: new HashSet<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Fitness_core_BE.DTOs.Place;
|
||||
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 System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PlaceService : IPlaceService
|
||||
{
|
||||
private readonly IPlaceRepo _placeRepo;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public PlaceService(IPlaceRepo placeRepo, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_placeRepo = placeRepo;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllPlaces(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var places = await _placeRepo.GetAllPlaces(clientId, includeInactive);
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(places.Select(p => p.PlaceId).ToList());
|
||||
|
||||
return places.Select(p => MapToDto(p, devicesByPlace.GetValueOrDefault(p.PlaceId))).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreatePlace(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var place = new Place
|
||||
{
|
||||
FKClientId = clientId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null
|
||||
};
|
||||
|
||||
var result = await _placeRepo.CreatePlace(place);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdatePlace(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
|
||||
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var existing = await _placeRepo.GetPlaceById(placeId)
|
||||
?? throw new KeyNotFoundException("Place not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
var result = await _placeRepo.UpdatePlace(existing);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { result.PlaceId });
|
||||
return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetPlaceStatus(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var isActive = data["isActive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var place = await _placeRepo.SetPlaceActive(placeId, clientId, isActive)
|
||||
?? throw new KeyNotFoundException("Place not found");
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { place.PlaceId });
|
||||
return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> AssignDevice(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
|
||||
|
||||
var placeId = Guid.Parse(data["placeId"].GetString()!);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var place = await _placeRepo.GetPlaceById(placeId);
|
||||
if (place == null || place.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Place not found");
|
||||
|
||||
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
|
||||
if (!hasDeviceId)
|
||||
{
|
||||
await _placeRepo.UnassignDeviceAsync(placeId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
|
||||
var device = await _placeRepo.GetDeviceByIdForClientAsync(deviceId, clientId)
|
||||
?? throw new KeyNotFoundException("Device not found");
|
||||
|
||||
if (await _placeRepo.IsDeviceAssignedToAnotherPlaceAsync(deviceId, placeId))
|
||||
throw new ArgumentException("This device is already assigned to another place.");
|
||||
|
||||
await _placeRepo.AssignDeviceAsync(placeId, deviceId);
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { placeId });
|
||||
return MapToDto(place, devicesByPlace.GetValueOrDefault(placeId));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlaceDto MapToDto(Place place, AccessDevice? device) => new()
|
||||
{
|
||||
PlaceId = place.PlaceId,
|
||||
Name = place.Name,
|
||||
Description = place.Description,
|
||||
IsActive = place.IsActive,
|
||||
CreatedAt = place.CreatedAt,
|
||||
DeviceId = device?.DeviceId,
|
||||
DeviceName = device?.DeviceName
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
using Fitness_core_BE.DTOs.PaymentGateways;
|
||||
using Fitness_core_BE.DTOs.PlatformBilling;
|
||||
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.Enums;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class PlatformBillingService : IPlatformBillingService
|
||||
{
|
||||
// The gateway a gym's saved ClientCard was onboarded through — see
|
||||
// ClientPaymentMethodService, which uses the same constant for onboarding.
|
||||
private const string GatewayName = "DirectPay";
|
||||
|
||||
private readonly IPlatformBillingRepository _repository;
|
||||
private readonly IClientStatRepository _clientStatRepository;
|
||||
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
||||
private readonly IThirdPartyService _thirdPartyService;
|
||||
private readonly IPaymentGatewayClient _paymentGatewayClient;
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly PlatformInvoicePaymentTokenHelper _paymentTokenHelper;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PlatformBillingService(
|
||||
IPlatformBillingRepository repository,
|
||||
IClientStatRepository clientStatRepository,
|
||||
IAccessDeviceRepo accessDeviceRepo,
|
||||
IThirdPartyService thirdPartyService,
|
||||
IPaymentGatewayClient paymentGatewayClient,
|
||||
IPaymentRepository paymentRepository,
|
||||
PlatformInvoicePaymentTokenHelper paymentTokenHelper,
|
||||
IConfiguration config,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_repository = repository;
|
||||
_clientStatRepository = clientStatRepository;
|
||||
_accessDeviceRepo = accessDeviceRepo;
|
||||
_thirdPartyService = thirdPartyService;
|
||||
_paymentGatewayClient = paymentGatewayClient;
|
||||
_paymentRepository = paymentRepository;
|
||||
_paymentTokenHelper = paymentTokenHelper;
|
||||
_config = config;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<List<PricingPlanDto>> GetPricingPlansAsync(CancellationToken ct = default)
|
||||
{
|
||||
var plans = await _repository.GetPricingPlansAsync(ct);
|
||||
|
||||
return plans.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<PricingPlanDto> UpsertPricingPlanAsync(UpsertPricingPlanRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
ClientPackage plan;
|
||||
if (request.ClientPackageId.HasValue)
|
||||
{
|
||||
plan = await _repository.GetPackageByIdAsync(
|
||||
request.ClientPackageId.Value, ct)
|
||||
?? throw new NotFoundException("Pricing plan not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
plan = new ClientPackage { ClientPackageId = Guid.NewGuid() };
|
||||
await _repository.AddPackageAsync(plan, ct);
|
||||
}
|
||||
|
||||
plan.PackageName = request.PackageName;
|
||||
plan.PackageDescription = request.PackageDescription;
|
||||
plan.PerMemberRate = request.PerMemberRate;
|
||||
plan.MinimumMonthlyBill = request.MinimumMonthlyBill;
|
||||
plan.MonthlyCap = request.MonthlyCap;
|
||||
plan.SetupFee = request.SetupFee;
|
||||
plan.IncludedMemberCount = request.IncludedMemberCount;
|
||||
plan.MaxBillableMemberCount = request.MaxBillableMemberCount;
|
||||
plan.OwnGatewayFeePercent = request.OwnGatewayFeePercent;
|
||||
plan.OwnGatewayFeeFixed = request.OwnGatewayFeeFixed;
|
||||
plan.OurGatewayFeePercent = request.OurGatewayFeePercent;
|
||||
plan.OurGatewayFeeFixed = request.OurGatewayFeeFixed;
|
||||
plan.PayoutDayOfMonth = request.PayoutDayOfMonth is >= 1 and <= 28 ? request.PayoutDayOfMonth : 1;
|
||||
plan.PackagePrice = request.MinimumMonthlyBill;
|
||||
|
||||
if (request.IsDefault)
|
||||
{
|
||||
// Only one plan may be default at a time.
|
||||
var currentDefaults = await _repository
|
||||
.GetOtherDefaultPackagesAsync(plan.ClientPackageId, ct);
|
||||
foreach (var d in currentDefaults)
|
||||
d.IsDefault = false;
|
||||
}
|
||||
plan.IsDefault = request.IsDefault;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return ToDto(plan);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DevicePricingDto> GetDevicePricingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await GetDeviceConfigOrDefaultAsync(ct);
|
||||
return new DevicePricingDto
|
||||
{
|
||||
DeviceRentalFeePerMonth = config.DeviceRentalFeePerMonth,
|
||||
DeviceInstallationFee = config.DeviceInstallationFee,
|
||||
DeviceConfigureExistingFee = config.DeviceConfigureExistingFee
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DevicePricingDto> UpdateDevicePricingAsync(DevicePricingDto request, CancellationToken ct = default)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var config = await _repository.GetDevicePricingConfigAsync(ct);
|
||||
if (config == null)
|
||||
{
|
||||
config = CreateDefaultDeviceConfig();
|
||||
await _repository.AddDevicePricingConfigAsync(config, ct);
|
||||
}
|
||||
config.DeviceRentalFeePerMonth = request.DeviceRentalFeePerMonth;
|
||||
config.DeviceInstallationFee = request.DeviceInstallationFee;
|
||||
config.DeviceConfigureExistingFee = request.DeviceConfigureExistingFee;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return request;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Models.ClientInvoice> AssignPricingPlanAsync(Guid clientId, AssignPricingPlanRequest request, CancellationToken ct = default)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var plan = await _repository.GetPackageByIdAsync(
|
||||
request.ClientPackageId, ct)
|
||||
?? throw new NotFoundException("Pricing plan not found");
|
||||
|
||||
var client = await _repository.GetClientByIdAsync(clientId, ct)
|
||||
?? throw new NotFoundException("Client not found");
|
||||
|
||||
var existingActive = await _repository
|
||||
.GetActiveClientSubscriptionsAsync(clientId, ct);
|
||||
foreach (var s in existingActive)
|
||||
s.IsActive = false;
|
||||
|
||||
await _repository.AddClientSubscriptionAsync(new ClientSubscription
|
||||
{
|
||||
SubscriptionId = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
FKClientPackageId = request.ClientPackageId,
|
||||
OnboardDate = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
}, ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode))
|
||||
{
|
||||
var existingDeviceBillings = await _repository
|
||||
.GetActiveDeviceBillingsAsync(clientId, ct);
|
||||
foreach (var d in existingDeviceBillings)
|
||||
d.IsActive = false;
|
||||
|
||||
await _repository.AddDeviceBillingAsync(new ClientDeviceBilling
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
Mode = request.DeviceBillingMode,
|
||||
AgreedAmount = request.DeviceAgreedAmount ?? 0,
|
||||
SetupFee = request.DeviceSetupFee ?? 0,
|
||||
IsActive = true,
|
||||
AssignedAt = DateTime.UtcNow
|
||||
}, ct);
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
// Combined onboarding payment: (machine full price OR setup-only for rental) +
|
||||
// setup fee + minimum-member-subscription fee + gateway fee (may be 0).
|
||||
var minMemberFee = plan.MinimumMonthlyBill;
|
||||
var gatewayFee = ComputeGatewayFee(client, plan, minMemberFee);
|
||||
|
||||
var extraItems = new List<(string Name, string Description, decimal Amount)>();
|
||||
if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode))
|
||||
{
|
||||
if (request.DeviceBillingMode == "FullPurchase")
|
||||
{
|
||||
extraItems.Add(("Access device (full purchase)", "One-time device purchase", request.DeviceAgreedAmount ?? 0));
|
||||
}
|
||||
if ((request.DeviceSetupFee ?? 0) > 0)
|
||||
{
|
||||
extraItems.Add(("Device setup fee", "One-time installation/setup", request.DeviceSetupFee ?? 0));
|
||||
}
|
||||
}
|
||||
if (minMemberFee > 0)
|
||||
{
|
||||
extraItems.Add(("Minimum member subscription fee", $"{plan.PackageName ?? "Standard"} plan minimum bill", minMemberFee));
|
||||
}
|
||||
if (gatewayFee > 0)
|
||||
{
|
||||
extraItems.Add(("Gateway fee", client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway ? "Own gateway fee" : "HexaFitness gateway fee", gatewayFee));
|
||||
}
|
||||
|
||||
var invoice = await CreatePlatformInvoiceAsync(clientId,
|
||||
"Plan setup",
|
||||
$"Setup for the {plan.PackageName ?? "Standard"} plan",
|
||||
plan.SetupFee,
|
||||
extraItems,
|
||||
ct);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return invoice;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlatformFeeBreakdownDto> ComputeMonthlyFeeAsync(Guid clientId, CancellationToken ct = default)
|
||||
{
|
||||
var client = await _repository.GetClientByIdAsync(clientId, ct)
|
||||
?? throw new NotFoundException("Client not found");
|
||||
var plan = await ResolvePlanForClientAsync(clientId, ct);
|
||||
var activeMembers = await _clientStatRepository.GetTotalCustomerCountAsync(clientId, ct);
|
||||
|
||||
var billableMembers = plan.MaxBillableMemberCount.HasValue
|
||||
? Math.Min(activeMembers, plan.MaxBillableMemberCount.Value)
|
||||
: activeMembers;
|
||||
var overageMembers = Math.Max(0, billableMembers - plan.IncludedMemberCount);
|
||||
|
||||
decimal baseFee = plan.MinimumMonthlyBill + overageMembers * plan.PerMemberRate;
|
||||
if (plan.MonthlyCap.HasValue)
|
||||
baseFee = Math.Min(baseFee, plan.MonthlyCap.Value);
|
||||
|
||||
var deviceFee = await ComputeDeviceFeeAsync(clientId, ct);
|
||||
var gatewayFee = ComputeGatewayFee(client, plan, baseFee);
|
||||
|
||||
return new PlatformFeeBreakdownDto
|
||||
{
|
||||
ClientId = clientId,
|
||||
PlanName = plan.PackageName,
|
||||
ActiveMemberCount = activeMembers,
|
||||
PerMemberRate = plan.PerMemberRate,
|
||||
MinimumMonthlyBill = plan.MinimumMonthlyBill,
|
||||
MonthlyCap = plan.MonthlyCap,
|
||||
BaseFee = baseFee,
|
||||
DeviceCount = deviceFee.DeviceCount,
|
||||
DeviceFee = deviceFee.Fee,
|
||||
GatewayFee = gatewayFee,
|
||||
TotalFee = baseFee + deviceFee.Fee + gatewayFee
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Models.ClientInvoice> GenerateMonthlyInvoiceAsync(Guid clientId, CancellationToken ct = default)
|
||||
{
|
||||
var breakdown = await ComputeMonthlyFeeAsync(clientId, ct);
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var extraItems = new List<(string, string, decimal)>();
|
||||
if (breakdown.DeviceCount > 0)
|
||||
extraItems.Add(("Access device rental", $"{breakdown.DeviceCount} unit(s)", breakdown.DeviceFee));
|
||||
if (breakdown.GatewayFee > 0)
|
||||
extraItems.Add(("Gateway fee", "Platform gateway fee for this billing period", breakdown.GatewayFee));
|
||||
|
||||
var invoice = await CreatePlatformInvoiceAsync(clientId,
|
||||
$"Platform fee — {breakdown.ActiveMemberCount} active member(s)",
|
||||
$"{breakdown.PlanName ?? "Standard"} plan — Rs. {breakdown.PerMemberRate}/member (min Rs. {breakdown.MinimumMonthlyBill})",
|
||||
breakdown.BaseFee,
|
||||
extraItems,
|
||||
ct);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return invoice;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Models.ClientInvoice> GenerateOnboardingInvoiceAsync(Guid clientId, CancellationToken ct = default)
|
||||
{
|
||||
var plan = await ResolvePlanForClientAsync(clientId, ct);
|
||||
var devicePricing = await GetDeviceConfigOrDefaultAsync(ct);
|
||||
var hasDevice = await _repository.HasActiveDeviceAsync(clientId, ct);
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await CreatePlatformInvoiceAsync(clientId,
|
||||
"Onboarding & setup fee",
|
||||
$"One-time setup fee for the {plan.PackageName ?? "Standard"} plan",
|
||||
plan.SetupFee,
|
||||
hasDevice
|
||||
? new[] { ("Access device installation", "One-time installation fee", devicePricing.DeviceInstallationFee) }
|
||||
: Array.Empty<(string, string, decimal)>(),
|
||||
ct);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return invoice;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Models.ClientInvoice>> RunMonthlyBillingForAllClientsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var clientIds = await _repository
|
||||
.GetMonthlyBillingCandidatesAsync(startOfMonth, ct);
|
||||
|
||||
var results = new List<Models.ClientInvoice>();
|
||||
foreach (var clientId in clientIds)
|
||||
{
|
||||
// Skip clients already billed for the current period, and clients with no card on file yet
|
||||
// (activation not completed) — nothing meaningful to invoice for those.
|
||||
results.Add(await GenerateMonthlyInvoiceAsync(clientId, ct));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Closes the loop RunMonthlyBillingForAllClientsAsync leaves open: generates any
|
||||
// invoices newly due this period, then attempts (or re-polls) a real Payment-Service
|
||||
// charge for every still-outstanding platform invoice that has a card on file.
|
||||
// AttemptPaymentAsync is idempotent per invoice (IdempotencyKey below), so simply
|
||||
// re-calling it on a later run is a safe, correct way to both charge new invoices and
|
||||
// poll ones still Processing from a prior run — no separate local "pending" bookkeeping.
|
||||
public async Task<SubscriptionChargeRunResult> RunAndChargeSubscriptionBillingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var result = new SubscriptionChargeRunResult();
|
||||
|
||||
var generated = await RunMonthlyBillingForAllClientsAsync(ct);
|
||||
result.InvoicesGenerated = generated.Count;
|
||||
|
||||
var outstanding = await _repository.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct);
|
||||
|
||||
foreach (var invoice in outstanding)
|
||||
{
|
||||
var balance = invoice.Amount - invoice.PaidAmount;
|
||||
if (balance <= 0)
|
||||
continue;
|
||||
|
||||
var clientId = invoice.ClientSubscription.FKClientId;
|
||||
var card = await _repository.GetClientCardAsync(clientId, ct);
|
||||
if (card is null || string.IsNullOrEmpty(card.CardToken))
|
||||
{
|
||||
result.SkippedNoCard++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The most recent charge attempt already recorded for this invoice, if any.
|
||||
// Payment-Service ties AmountMinorUnits to the IdempotencyKey permanently (a
|
||||
// mismatched amount on replay throws), so a fresh charge always uses the
|
||||
// invoice's current balance, but re-polling an in-flight one must reuse the
|
||||
// exact amount it was created with.
|
||||
var existingTxn = (await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(invoice.ClientInvoiceId))
|
||||
.Where(t => t.RefType == "platform-subscription")
|
||||
.OrderByDescending(t => t.PayDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existingTxn?.PayStatus == "Failed")
|
||||
{
|
||||
// Payment-Service payments can never leave Failed — left Pending for the
|
||||
// existing manual pay-link fallback (SendClientPaymentLinkAsync) rather than
|
||||
// retried automatically forever.
|
||||
result.SkippedPreviouslyFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
decimal chargeAmount;
|
||||
if (existingTxn?.PayStatus == "Processing")
|
||||
{
|
||||
if (existingTxn.PayAmount != balance)
|
||||
{
|
||||
// The balance moved since the in-flight attempt was created (e.g. a
|
||||
// manual partial payment) — retrying would either overcharge or hit
|
||||
// Payment-Service's idempotency-conflict check. Needs manual review.
|
||||
result.SkippedAmountMismatch++;
|
||||
continue;
|
||||
}
|
||||
chargeAmount = existingTxn.PayAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
chargeAmount = balance;
|
||||
}
|
||||
|
||||
AttemptPaymentResult attempt;
|
||||
try
|
||||
{
|
||||
attempt = await _paymentGatewayClient.AttemptPaymentAsync(clientId, new AttemptPaymentRequest
|
||||
{
|
||||
IdempotencyKey = $"platform-invoice-{invoice.ClientInvoiceId}",
|
||||
GatewayName = GatewayName,
|
||||
CardToken = card.CardToken,
|
||||
AmountMinorUnits = chargeAmount * 100m,
|
||||
Currency = "LKR",
|
||||
Description = $"HexaFitness platform invoice {invoice.ClientInvoiceId}"
|
||||
}, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal for the rest of the run: this invoice is retried (idempotently)
|
||||
// next run, but the caller needs to see the run wasn't fully clean.
|
||||
Console.WriteLine($"Subscription charge attempt failed for invoice {invoice.ClientInvoiceId}: {ex.Message}");
|
||||
result.Errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.ChargesAttempted++;
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
if (existingTxn is null || existingTxn.PaymentReferenceId != attempt.PaymentId.ToString())
|
||||
{
|
||||
await _paymentRepository.CreatePaymentAttemptAsync(new PaymentTransaction
|
||||
{
|
||||
PayDate = attempt.CreatedAtUtc,
|
||||
PayStatus = attempt.Status,
|
||||
PayAmount = chargeAmount,
|
||||
FK_ClientInvoiceId = invoice.ClientInvoiceId,
|
||||
FK_ClientId = clientId,
|
||||
BeneficiaryType = BeneficiaryType.Client,
|
||||
RefType = "platform-subscription",
|
||||
PaymentReferenceId = attempt.PaymentId.ToString(),
|
||||
IsActive = true,
|
||||
IsApproved = attempt.Status == "Completed"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingTxn.PayStatus = attempt.Status;
|
||||
existingTxn.IsApproved = attempt.Status == "Completed";
|
||||
await _paymentRepository.UpdatePaymentAttemptAsync(existingTxn);
|
||||
}
|
||||
|
||||
if (attempt.Status == "Completed")
|
||||
{
|
||||
invoice.PaidAmount += chargeAmount;
|
||||
invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial";
|
||||
result.Paid++;
|
||||
}
|
||||
else if (attempt.Status == "Failed")
|
||||
{
|
||||
result.Failed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.StillProcessing++;
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<PendingPlatformInvoiceDto>> GetPendingInvoicesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var pending = await _repository
|
||||
.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct);
|
||||
|
||||
return pending.Select(ToPendingDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<PendingPlatformInvoiceDto>> GetCompletedInvoicesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var completed = await _repository
|
||||
.GetInvoicesByStatusAsync("Paid", newestFirst: true, ct);
|
||||
|
||||
return completed.Select(ToPendingDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<PendingPlatformInvoiceDto> RecordClientInvoicePaymentAsync(Guid clientInvoiceId, decimal amount, CancellationToken ct = default)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new BadRequestException("Payment amount must be greater than zero.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await _repository.GetInvoiceWithClientAsync(
|
||||
clientInvoiceId, includeBusinesses: true, ct)
|
||||
?? throw new NotFoundException("Platform invoice not found");
|
||||
|
||||
var balance = invoice.Amount - invoice.PaidAmount;
|
||||
if (balance <= 0)
|
||||
throw new BadRequestException("This invoice is already fully paid.");
|
||||
|
||||
invoice.PaidAmount += Math.Min(amount, balance);
|
||||
invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial";
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var client = invoice.ClientSubscription.Client;
|
||||
if (client != null)
|
||||
await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: invoice.Status != "Paid");
|
||||
|
||||
return ToPendingDto(invoice);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SendClientPaymentLinkAsync(Guid clientInvoiceId, CancellationToken ct = default)
|
||||
{
|
||||
var invoice = await _repository.GetInvoiceWithClientAsync(
|
||||
clientInvoiceId, includeBusinesses: false, ct)
|
||||
?? throw new NotFoundException("Platform invoice not found");
|
||||
|
||||
var client = invoice.ClientSubscription.Client
|
||||
?? throw new NotFoundException("Client not found for this invoice");
|
||||
|
||||
if (invoice.Amount - invoice.PaidAmount <= 0)
|
||||
throw new BadRequestException("This invoice is already fully paid.");
|
||||
|
||||
await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: true);
|
||||
return new { sent = true };
|
||||
}
|
||||
|
||||
private async Task NotifyPaymentActivityAsync(Client client, Models.ClientInvoice invoice, bool sendPaymentLink)
|
||||
{
|
||||
var balance = invoice.Amount - invoice.PaidAmount;
|
||||
var message = invoice.Status == "Paid"
|
||||
? $"HexaFitness: payment of Rs. {invoice.PaidAmount:N2} received. Thank you!"
|
||||
: $"HexaFitness: Rs. {balance:N2} due on your account.";
|
||||
|
||||
if (sendPaymentLink && balance > 0)
|
||||
{
|
||||
var token = _paymentTokenHelper.Generate(invoice.ClientInvoiceId);
|
||||
var baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/');
|
||||
var link = $"{baseUrl}/pay-platform/{token}";
|
||||
message += $" Pay online: {link}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(client.ClientPhone))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _thirdPartyService.SendSmsAsync(client.ClientPhone!, message: message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Platform invoice SMS failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int DeviceCount, decimal Fee)> ComputeDeviceFeeAsync(Guid clientId, CancellationToken ct)
|
||||
{
|
||||
var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId);
|
||||
var activeDeviceCount = devices.Count(d => d.IsActive);
|
||||
|
||||
var deviceBilling = await _repository
|
||||
.GetLatestActiveDeviceBillingAsync(clientId, ct);
|
||||
|
||||
if (deviceBilling != null)
|
||||
{
|
||||
// Rental contributes a recurring monthly fee; a full purchase is one-time
|
||||
// (already billed at assignment time) and adds nothing to the recurring fee.
|
||||
var fee = deviceBilling.Mode == "Rental" ? deviceBilling.AgreedAmount : 0;
|
||||
return (activeDeviceCount, fee);
|
||||
}
|
||||
|
||||
// No per-client agreement recorded yet: fall back to the global default rate.
|
||||
var devicePricing = await GetDeviceConfigOrDefaultAsync(ct);
|
||||
return (activeDeviceCount, activeDeviceCount * devicePricing.DeviceRentalFeePerMonth);
|
||||
}
|
||||
|
||||
private static decimal ComputeGatewayFee(Client client, ClientPackage plan, decimal baseAmount)
|
||||
{
|
||||
var isOwnGateway = client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway;
|
||||
var percent = isOwnGateway ? plan.OwnGatewayFeePercent : plan.OurGatewayFeePercent;
|
||||
var fixedFee = isOwnGateway ? plan.OwnGatewayFeeFixed : plan.OurGatewayFeeFixed;
|
||||
return Math.Round(baseAmount * percent / 100m, 2) + fixedFee;
|
||||
}
|
||||
|
||||
private async Task<Models.ClientInvoice> CreatePlatformInvoiceAsync(
|
||||
Guid clientId,
|
||||
string primaryItemName,
|
||||
string primaryItemDescription,
|
||||
decimal primaryItemAmount,
|
||||
IEnumerable<(string Name, string Description, decimal Amount)> extraItems,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var client = await _repository.GetClientByIdAsync(clientId, ct)
|
||||
?? throw new NotFoundException("Client not found");
|
||||
|
||||
var subscription = await _repository
|
||||
.GetActiveClientSubscriptionAsync(clientId, ct);
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
var defaultPlan = await _repository.GetDefaultPackageAsync(ct);
|
||||
if (defaultPlan == null)
|
||||
{
|
||||
defaultPlan = CreateDefaultPlan();
|
||||
await _repository.AddPackageAsync(defaultPlan, ct);
|
||||
}
|
||||
subscription = new ClientSubscription
|
||||
{
|
||||
SubscriptionId = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
FKClientPackageId = defaultPlan.ClientPackageId,
|
||||
OnboardDate = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
await _repository.AddClientSubscriptionAsync(subscription, ct);
|
||||
}
|
||||
|
||||
var clientCard = await _repository.GetClientCardAsync(clientId, ct)
|
||||
?? throw new BadRequestException("No card on file for this gym yet — cannot generate an invoice.");
|
||||
|
||||
var items = new List<(string Name, string Description, decimal Amount)>
|
||||
{
|
||||
(primaryItemName, primaryItemDescription, primaryItemAmount)
|
||||
};
|
||||
items.AddRange(extraItems);
|
||||
|
||||
var invoice = new Models.ClientInvoice
|
||||
{
|
||||
ClientInvoiceId = Guid.NewGuid(),
|
||||
FKClientSubscriptionId = subscription.SubscriptionId,
|
||||
FKUserId = client.FKUserId,
|
||||
FKClientCardId = clientCard.CardId,
|
||||
Amount = items.Sum(i => i.Amount),
|
||||
PaidAmount = 0,
|
||||
Status = "Pending",
|
||||
ClientInvoiceDate = DateTime.UtcNow
|
||||
};
|
||||
var invoiceItems = items
|
||||
.Select(item => new ClientInvoiceItem
|
||||
{
|
||||
ClientInvoiceItemId = Guid.NewGuid(),
|
||||
FKClientInvoiceId = invoice.ClientInvoiceId,
|
||||
ItemName = item.Name,
|
||||
ItemDescription = item.Description,
|
||||
ItemPrice = item.Amount,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
await _repository.AddInvoiceAsync(invoice, invoiceItems, ct);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async Task<ClientPackage> ResolvePlanForClientAsync(Guid clientId, CancellationToken ct)
|
||||
{
|
||||
return await _repository.GetActivePackageForClientAsync(clientId, ct)
|
||||
?? await _repository.GetDefaultPackageAsync(ct)
|
||||
?? CreateDefaultPlan();
|
||||
}
|
||||
|
||||
private static ClientPackage CreateDefaultPlan()
|
||||
{
|
||||
return new ClientPackage
|
||||
{
|
||||
ClientPackageId = Guid.NewGuid(),
|
||||
PackageName = "Standard",
|
||||
PackageDescription = "Default platform pricing plan",
|
||||
PerMemberRate = 0,
|
||||
MinimumMonthlyBill = 0,
|
||||
MonthlyCap = null,
|
||||
SetupFee = 0,
|
||||
IsDefault = true,
|
||||
IsActive = true
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<DevicePricingConfig> GetDeviceConfigOrDefaultAsync(CancellationToken ct)
|
||||
{
|
||||
return await _repository.GetDevicePricingConfigAsync(ct)
|
||||
?? CreateDefaultDeviceConfig();
|
||||
}
|
||||
|
||||
private static DevicePricingConfig CreateDefaultDeviceConfig() =>
|
||||
new() { DevicePricingConfigId = Guid.NewGuid() };
|
||||
|
||||
private static PricingPlanDto ToDto(ClientPackage plan) => new()
|
||||
{
|
||||
ClientPackageId = plan.ClientPackageId,
|
||||
PackageName = plan.PackageName,
|
||||
PackageDescription = plan.PackageDescription,
|
||||
PerMemberRate = plan.PerMemberRate,
|
||||
MinimumMonthlyBill = plan.MinimumMonthlyBill,
|
||||
MonthlyCap = plan.MonthlyCap,
|
||||
SetupFee = plan.SetupFee,
|
||||
IncludedMemberCount = plan.IncludedMemberCount,
|
||||
MaxBillableMemberCount = plan.MaxBillableMemberCount,
|
||||
OwnGatewayFeePercent = plan.OwnGatewayFeePercent,
|
||||
OwnGatewayFeeFixed = plan.OwnGatewayFeeFixed,
|
||||
OurGatewayFeePercent = plan.OurGatewayFeePercent,
|
||||
OurGatewayFeeFixed = plan.OurGatewayFeeFixed,
|
||||
PayoutDayOfMonth = plan.PayoutDayOfMonth,
|
||||
IsDefault = plan.IsDefault,
|
||||
IsActive = plan.IsActive
|
||||
};
|
||||
|
||||
private static PendingPlatformInvoiceDto ToPendingDto(Models.ClientInvoice invoice)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return new PendingPlatformInvoiceDto
|
||||
{
|
||||
ClientInvoiceId = invoice.ClientInvoiceId,
|
||||
ClientId = invoice.ClientSubscription.FKClientId,
|
||||
ClientName = invoice.ClientSubscription.Client?.ClientName,
|
||||
City = invoice.ClientSubscription.Client?.ClientBusinesses?.FirstOrDefault()?.City,
|
||||
Amount = invoice.Amount,
|
||||
PaidAmount = invoice.PaidAmount,
|
||||
Status = invoice.Status,
|
||||
ClientInvoiceDate = invoice.ClientInvoiceDate,
|
||||
DaysOverdue = Math.Max(0, (int)(now - invoice.ClientInvoiceDate).TotalDays)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Fitness_core_BE.DTOs.Ledger;
|
||||
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;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class ProfitLossService : IProfitLossService
|
||||
{
|
||||
private readonly IProfitLossRepository _repository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ProfitLossService(
|
||||
IProfitLossRepository repository,
|
||||
UserRoleHelper userRoleHelper,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_repository = repository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ProfitLossDto> GetProfitLossAsync(DateTime from, DateTime to, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetClientIdAsync(httpContext);
|
||||
var (fromUtc, toUtc) = NormalizeRange(from, to);
|
||||
|
||||
var entries = await _repository.GetDerivedEntriesAsync(
|
||||
clientId, fromUtc, toUtc);
|
||||
|
||||
// Manual entries.
|
||||
var manualEntries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc);
|
||||
entries.AddRange(manualEntries.Select(ToDto));
|
||||
|
||||
var totalIncome = entries.Where(e => e.EntryType == "Income").Sum(e => e.Amount);
|
||||
var totalExpense = entries.Where(e => e.EntryType == "Expense").Sum(e => e.Amount);
|
||||
|
||||
return new ProfitLossDto
|
||||
{
|
||||
From = fromUtc,
|
||||
To = toUtc,
|
||||
TotalIncome = totalIncome,
|
||||
TotalExpense = totalExpense,
|
||||
NetProfit = totalIncome - totalExpense,
|
||||
IncomeByCategory = entries.Where(e => e.EntryType == "Income")
|
||||
.GroupBy(e => e.Category)
|
||||
.Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.OrderByDescending(c => c.Amount)
|
||||
.ToList(),
|
||||
ExpenseByCategory = entries.Where(e => e.EntryType == "Expense")
|
||||
.GroupBy(e => e.Category)
|
||||
.Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||
.OrderByDescending(c => c.Amount)
|
||||
.ToList(),
|
||||
Entries = entries.OrderByDescending(e => e.EntryDate).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<LedgerEntryDto>> GetManualEntriesAsync(DateTime from, DateTime to, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetClientIdAsync(httpContext);
|
||||
var (fromUtc, toUtc) = NormalizeRange(from, to);
|
||||
var entries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc);
|
||||
return entries.Select(ToDto).OrderByDescending(e => e.EntryDate).ToList();
|
||||
}
|
||||
|
||||
public async Task<LedgerEntryDto> AddManualEntryAsync(AddLedgerEntryRequest request, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetClientIdAsync(httpContext);
|
||||
|
||||
if (request.EntryType != "Income" && request.EntryType != "Expense")
|
||||
throw new ArgumentException("entryType must be 'Income' or 'Expense'.");
|
||||
if (string.IsNullOrWhiteSpace(request.Category))
|
||||
throw new ArgumentException("category is required.");
|
||||
if (request.Amount <= 0)
|
||||
throw new ArgumentException("amount must be greater than zero.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var entry = new LedgerEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
EntryType = request.EntryType,
|
||||
Category = request.Category,
|
||||
Description = request.Description,
|
||||
Amount = request.Amount,
|
||||
EntryDate = request.EntryDate ?? DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _repository.AddManualEntryAsync(entry);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return ToDto(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteManualEntryAsync(Guid id, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await GetClientIdAsync(httpContext);
|
||||
|
||||
var entry = await _repository.GetManualEntryByIdAsync(id)
|
||||
?? throw new NotFoundException("Ledger entry not found.");
|
||||
|
||||
if (entry.FKClientId != clientId)
|
||||
throw new ForbiddenException("This ledger entry does not belong to your gym.");
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
_repository.RemoveManualEntry(entry);
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<LedgerEntry>> GetManualEntriesRawAsync(Guid clientId, DateTime fromUtc, DateTime toUtc)
|
||||
{
|
||||
return await _repository.GetManualEntriesAsync(clientId, fromUtc, toUtc);
|
||||
}
|
||||
|
||||
private async Task<Guid> GetClientIdAsync(HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
return await _userRoleHelper.GetClientId(userId);
|
||||
}
|
||||
|
||||
private static (DateTime From, DateTime To) NormalizeRange(DateTime from, DateTime to)
|
||||
{
|
||||
var fromUtc = DateTime.SpecifyKind(from.Date, DateTimeKind.Utc);
|
||||
// Treat `to` as inclusive of the whole day.
|
||||
var toUtc = DateTime.SpecifyKind(to.Date.AddDays(1), DateTimeKind.Utc);
|
||||
return (fromUtc, toUtc);
|
||||
}
|
||||
|
||||
private static LedgerEntryDto ToDto(LedgerEntry entry) => new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
EntryType = entry.EntryType,
|
||||
Category = entry.Category,
|
||||
Description = entry.Description,
|
||||
Amount = entry.Amount,
|
||||
EntryDate = entry.EntryDate,
|
||||
IsManual = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
// Sends real device pushes via Expo's push API and, independently, persists a
|
||||
// CustomerNotification row so the mobile Notification tab always has a history even if the
|
||||
// push itself never reached the device (permission denied, no token registered, etc.).
|
||||
public class PushNotificationService : IPushNotificationService
|
||||
{
|
||||
private const string ExpoPushUrl = "https://exp.host/--/api/v2/push/send";
|
||||
private const int ExpoBatchSize = 100;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ICustomerNotificationRepository _repository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PushNotificationService(IHttpClientFactory httpClientFactory, ICustomerNotificationRepository repository, IUnitOfWork uow)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_repository = repository;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task NotifyCustomerAsync(
|
||||
Guid customerId,
|
||||
string title,
|
||||
string body,
|
||||
string notificationType,
|
||||
string? routeName = null,
|
||||
object? routeParams = null)
|
||||
{
|
||||
var notification = BuildNotification(customerId, title, body, notificationType, routeName, routeParams);
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _repository.CreateAsync(notification);
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var pushToken = await _repository.GetCustomerPushTokenAsync(customerId);
|
||||
if (IsExpoPushToken(pushToken))
|
||||
{
|
||||
await SendExpoPushBatchAsync(new[] { pushToken! }, title, body, notificationType, routeName, routeParams);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyCustomersAsync(
|
||||
IEnumerable<Guid> customerIds,
|
||||
string title,
|
||||
string body,
|
||||
string notificationType,
|
||||
string? routeName = null,
|
||||
object? routeParams = null)
|
||||
{
|
||||
var ids = customerIds.Distinct().ToList();
|
||||
if (ids.Count == 0) return;
|
||||
|
||||
var notifications = ids
|
||||
.Select(id => BuildNotification(id, title, body, notificationType, routeName, routeParams))
|
||||
.ToList();
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _repository.CreateManyAsync(notifications);
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var tokens = await _repository.GetCustomerPushTokensAsync(ids);
|
||||
var validTokens = tokens.Where(IsExpoPushToken).ToList();
|
||||
|
||||
for (var offset = 0; offset < validTokens.Count; offset += ExpoBatchSize)
|
||||
{
|
||||
var chunk = validTokens.Skip(offset).Take(ExpoBatchSize).ToList();
|
||||
await SendExpoPushBatchAsync(chunk, title, body, notificationType, routeName, routeParams);
|
||||
}
|
||||
}
|
||||
|
||||
private static CustomerNotification BuildNotification(
|
||||
Guid customerId, string title, string body, string notificationType, string? routeName, object? routeParams)
|
||||
{
|
||||
return new CustomerNotification
|
||||
{
|
||||
CustomerNotificationId = Guid.NewGuid(),
|
||||
FKCustomerId = customerId,
|
||||
Title = title,
|
||||
Body = body,
|
||||
NotificationType = notificationType,
|
||||
RouteName = routeName,
|
||||
RouteParamsJson = routeParams != null ? JsonSerializer.Serialize(routeParams) : null,
|
||||
IsRead = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsExpoPushToken(string? token) =>
|
||||
!string.IsNullOrWhiteSpace(token) && token.StartsWith("ExponentPushToken", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task SendExpoPushBatchAsync(
|
||||
IReadOnlyCollection<string> tokens, string title, string body, string notificationType, string? routeName, object? routeParams)
|
||||
{
|
||||
if (tokens.Count == 0) return;
|
||||
|
||||
var messages = tokens.Select(token => new
|
||||
{
|
||||
to = token,
|
||||
title,
|
||||
body,
|
||||
sound = "default",
|
||||
data = new
|
||||
{
|
||||
notificationType = routeName != null ? "navigate" : "normal",
|
||||
routeName,
|
||||
routeParams,
|
||||
notificationKind = notificationType
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(ExpoPushUrl, messages);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"Expo push send failed ({(int)response.StatusCode}): {responseBody}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Push delivery is best-effort — the notification is already persisted above,
|
||||
// so a network hiccup here must not fail the caller's larger operation.
|
||||
Console.WriteLine($"Expo push send threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Models.Receipt;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class ReceiptService : IReceiptService
|
||||
{
|
||||
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
|
||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
||||
private readonly IClientRepository _clientRepository;
|
||||
private readonly IClientInvoiceRepository _clientInvoiceRepository;
|
||||
private readonly IPaymentRepository _paymentRepository;
|
||||
private readonly IThirdPartyService _thirdPartyService;
|
||||
|
||||
public ReceiptService(
|
||||
ICustomerInvoiceRepository customerInvoiceRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
IClientRepository clientRepository,
|
||||
IClientInvoiceRepository clientInvoiceRepository,
|
||||
IPaymentRepository paymentRepository,
|
||||
IThirdPartyService thirdPartyService)
|
||||
{
|
||||
_customerInvoiceRepository = customerInvoiceRepository;
|
||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
||||
_clientRepository = clientRepository;
|
||||
_clientInvoiceRepository = clientInvoiceRepository;
|
||||
_paymentRepository = paymentRepository;
|
||||
_thirdPartyService = thirdPartyService;
|
||||
}
|
||||
|
||||
public async Task<ReceiptModel> BuildForInvoiceAsync(Guid customerInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null)
|
||||
{
|
||||
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
|
||||
?? throw new NotFoundException("Invoice not found");
|
||||
|
||||
if (requireClientId.HasValue && invoice.FKClientId != requireClientId.Value)
|
||||
throw new UnauthorizedException("This invoice does not belong to your gym.");
|
||||
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId);
|
||||
string planName = subscription?.CustomerPlan?.PlanName ?? "Membership";
|
||||
Guid? customerId = subscription?.FKCustomerId;
|
||||
|
||||
Customer? customer = customerId.HasValue ? await _clientRepository.GetCustomerById(customerId.Value) : null;
|
||||
var client = await _clientRepository.GetClientByIdAsync(invoice.FKClientId);
|
||||
string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName
|
||||
?? client?.ClientName
|
||||
?? "GYM OS";
|
||||
|
||||
decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId);
|
||||
|
||||
return new ReceiptModel
|
||||
{
|
||||
ClientId = invoice.FKClientId,
|
||||
ReceiptNo = $"GYMOS-{customerInvoiceId.ToString("N")[..8].ToUpperInvariant()}",
|
||||
Date = DateTime.UtcNow,
|
||||
GymName = gymName,
|
||||
MemberName = customer?.CusName ?? "Member",
|
||||
MemberPhone = customer?.PrimaryPH,
|
||||
MemberEmail = customer?.Email,
|
||||
PlanName = planName,
|
||||
AmountPaid = paymentAmount ?? paid,
|
||||
InvoiceAmount = invoice.Amount,
|
||||
Balance = invoice.Amount - paid,
|
||||
Currency = "LKR"
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReceiptModel> BuildForClientInvoiceAsync(Guid clientInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null)
|
||||
{
|
||||
var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId)
|
||||
?? throw new NotFoundException("Invoice not found");
|
||||
|
||||
var clientId = invoice.ClientSubscription?.FKClientId ?? Guid.Empty;
|
||||
if (requireClientId.HasValue && clientId != requireClientId.Value)
|
||||
throw new UnauthorizedException("This invoice does not belong to your gym.");
|
||||
|
||||
var client = invoice.ClientSubscription?.Client ?? await _clientRepository.GetClientByIdAsync(clientId);
|
||||
string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName
|
||||
?? client?.ClientName
|
||||
?? "Gym";
|
||||
|
||||
var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId);
|
||||
decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount);
|
||||
|
||||
string itemSummary = invoice.Items?.FirstOrDefault()?.ItemName ?? "Platform fee";
|
||||
|
||||
return new ReceiptModel
|
||||
{
|
||||
ClientId = clientId,
|
||||
ReceiptNo = $"HEXA-{clientInvoiceId.ToString("N")[..8].ToUpperInvariant()}",
|
||||
Date = DateTime.UtcNow,
|
||||
GymName = "HexaFitness",
|
||||
MemberName = gymName,
|
||||
MemberPhone = client?.ClientPhone,
|
||||
MemberEmail = client?.ClientEmail,
|
||||
PlanName = itemSummary,
|
||||
AmountPaid = paymentAmount ?? paid,
|
||||
InvoiceAmount = invoice.Amount,
|
||||
Balance = invoice.Amount - paid,
|
||||
Currency = "LKR"
|
||||
};
|
||||
}
|
||||
|
||||
public byte[] GeneratePdf(ReceiptModel m)
|
||||
{
|
||||
string money(decimal v) => $"{m.Currency} {v:N2}";
|
||||
|
||||
var document = QuestPDF.Fluent.Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A5);
|
||||
page.Margin(28);
|
||||
page.DefaultTextStyle(x => x.FontSize(11).FontColor(Colors.Grey.Darken3));
|
||||
|
||||
page.Header().Column(col =>
|
||||
{
|
||||
col.Item().Text("GYM OS").FontSize(20).Bold().FontColor(Colors.Blue.Darken2);
|
||||
col.Item().Text(m.GymName).FontSize(13).SemiBold();
|
||||
col.Item().PaddingTop(2).Text("Payment Receipt").FontSize(10).FontColor(Colors.Grey.Medium);
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
|
||||
page.Content().PaddingVertical(12).Column(col =>
|
||||
{
|
||||
col.Spacing(6);
|
||||
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text($"Receipt No: {m.ReceiptNo}");
|
||||
row.RelativeItem().AlignRight().Text($"Date: {m.Date:yyyy-MM-dd}");
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(4).Text("Billed to").FontColor(Colors.Grey.Medium).FontSize(9);
|
||||
col.Item().Text(m.MemberName).SemiBold();
|
||||
if (!string.IsNullOrWhiteSpace(m.MemberPhone))
|
||||
col.Item().Text(m.MemberPhone!).FontSize(10);
|
||||
if (!string.IsNullOrWhiteSpace(m.MemberEmail))
|
||||
col.Item().Text(m.MemberEmail!).FontSize(10);
|
||||
|
||||
col.Item().PaddingTop(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(c =>
|
||||
{
|
||||
c.RelativeColumn(3);
|
||||
c.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Cell().Element(HeaderCell).Text("Description");
|
||||
table.Cell().Element(HeaderCell).AlignRight().Text("Amount");
|
||||
|
||||
table.Cell().Element(BodyCell).Text(m.PlanName);
|
||||
table.Cell().Element(BodyCell).AlignRight().Text(money(m.AmountPaid));
|
||||
|
||||
static IContainer HeaderCell(IContainer c) =>
|
||||
c.Background(Colors.Grey.Lighten3).PaddingVertical(5).PaddingHorizontal(8).DefaultTextStyle(t => t.SemiBold());
|
||||
static IContainer BodyCell(IContainer c) =>
|
||||
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(5).PaddingHorizontal(8);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).AlignRight().Column(totals =>
|
||||
{
|
||||
totals.Item().Text($"Amount paid: {money(m.AmountPaid)}").SemiBold();
|
||||
totals.Item().Text($"Invoice total: {money(m.InvoiceAmount)}").FontSize(10).FontColor(Colors.Grey.Medium);
|
||||
totals.Item().Text($"Balance due: {money(m.Balance)}").FontSize(10)
|
||||
.FontColor(m.Balance > 0 ? Colors.Red.Medium : Colors.Green.Darken1);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Column(col =>
|
||||
{
|
||||
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
col.Item().PaddingTop(6).Text("Thank you for your payment.").FontSize(10);
|
||||
col.Item().Text($"{m.GymName} · powered by GYM OS").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
public async Task SendReceiptAsync(ReceiptModel m)
|
||||
{
|
||||
byte[] pdf;
|
||||
try
|
||||
{
|
||||
pdf = GeneratePdf(m);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Receipt PDF generation failed (non-critical): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(m.MemberEmail))
|
||||
{
|
||||
try
|
||||
{
|
||||
string html =
|
||||
$"<p>Hi {m.MemberName},</p>" +
|
||||
$"<p>Thank you for your payment of <b>{m.Currency} {m.AmountPaid:N2}</b> for <b>{m.PlanName}</b> at {m.GymName}.</p>" +
|
||||
(m.Balance > 0 ? $"<p>Outstanding balance: {m.Currency} {m.Balance:N2}.</p>" : "") +
|
||||
$"<p>Your receipt ({m.ReceiptNo}) is attached.</p>" +
|
||||
$"<p>— {m.GymName}<br/><small>powered by GYM OS</small></p>";
|
||||
await _thirdPartyService.SendEmailAsync(m.MemberEmail!, $"Receipt {m.ReceiptNo} — {m.GymName}", html, pdf, $"{m.ReceiptNo}.pdf");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Receipt email failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(m.MemberPhone))
|
||||
{
|
||||
try
|
||||
{
|
||||
string sms = $"You paid {m.Currency} {m.AmountPaid:N2} for {m.PlanName} at {m.GymName}. Thank you! - GYM OS";
|
||||
await _thirdPartyService.SendSmsAsync(m.MemberPhone!, null, sms);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Receipt SMS failed (non-critical): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
using Fitness_core_BE.DTOs.Auth;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.DTOs.Staff;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services.Staff
|
||||
{
|
||||
public class StaffService : IStaffService
|
||||
{
|
||||
private readonly IStaffRepository _staffRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IAccessDeviceService _accessDeviceService;
|
||||
private readonly IGymRoleRepository _gymRoleRepository;
|
||||
|
||||
public StaffService(IStaffRepository staffRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper, IAuthService authService, IAccessDeviceService accessDeviceService, IGymRoleRepository gymRoleRepository)
|
||||
{
|
||||
_staffRepository = staffRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_authService = authService;
|
||||
_accessDeviceService = accessDeviceService;
|
||||
_gymRoleRepository = gymRoleRepository;
|
||||
}
|
||||
|
||||
public async Task<object> GetAllStaff(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
bool includeInactive = data != null &&
|
||||
data.ContainsKey("includeInactive") &&
|
||||
data["includeInactive"].GetBoolean();
|
||||
|
||||
var staffList = await _staffRepository.GetAllStaff(includeInactive);
|
||||
|
||||
return await MapStaffListAsync(staffList);
|
||||
}
|
||||
|
||||
public async Task<object> GetStaffById(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("staffId"))
|
||||
{
|
||||
throw new KeyNotFoundException("staffId is required");
|
||||
}
|
||||
|
||||
var staffId = Guid.Parse(data["staffId"].GetString()!);
|
||||
var staff = await _staffRepository.GetStaffById(staffId);
|
||||
|
||||
if (staff == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Staff not found");
|
||||
}
|
||||
var callerClientId = await _userRoleHelper.GetClientId(
|
||||
DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (staff.FKClientId != callerClientId)
|
||||
throw new ForbiddenException("This staff member does not belong to your gym.");
|
||||
|
||||
return await MapStaffAsync(staff);
|
||||
}
|
||||
|
||||
public async Task<object> GetStaffByClientId(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("clientId"))
|
||||
{
|
||||
throw new KeyNotFoundException("clientId is required");
|
||||
}
|
||||
|
||||
var clientId = Guid.Parse(data["clientId"].GetString()!);
|
||||
var callerClientId = await _userRoleHelper.GetClientId(
|
||||
DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (clientId != callerClientId)
|
||||
throw new ForbiddenException("You cannot view another gym's staff.");
|
||||
|
||||
bool includeInactive = data.ContainsKey("includeInactive") &&
|
||||
data["includeInactive"].GetBoolean();
|
||||
|
||||
var staffList = await _staffRepository.GetStaffByClientId(clientId, includeInactive);
|
||||
|
||||
return await MapStaffListAsync(staffList);
|
||||
}
|
||||
|
||||
public async Task<object> CreateStaff(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
if (!data!.ContainsKey("userName"))
|
||||
{
|
||||
throw new KeyNotFoundException("userName is required");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("email"))
|
||||
{
|
||||
throw new KeyNotFoundException("email is required");
|
||||
}
|
||||
|
||||
if (!data.ContainsKey("phoneNumber"))
|
||||
{
|
||||
throw new KeyNotFoundException("phoneNumber is required");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
UserName = data["userName"].GetString()!,
|
||||
Email = data["email"].GetString()!,
|
||||
PhoneNumber = data["phoneNumber"].GetString()!
|
||||
};
|
||||
|
||||
// Optional -- must belong to this same client, same check AssignStaffRole
|
||||
// already does for the post-creation assignment path. Left unset, the new
|
||||
// staff member has no dashboard access until a role is assigned later.
|
||||
Guid? gymRoleId = null;
|
||||
if (data.ContainsKey("gymRoleId") && data["gymRoleId"].ValueKind != JsonValueKind.Null
|
||||
&& Guid.TryParse(data["gymRoleId"].GetString(), out var parsedRoleId))
|
||||
{
|
||||
var role = await _gymRoleRepository.GetRoleById(parsedRoleId);
|
||||
if (role == null || role.FKClientId != clientId)
|
||||
throw new BadRequestException("That role doesn't belong to your gym.");
|
||||
gymRoleId = parsedRoleId;
|
||||
}
|
||||
|
||||
var staff = new Models.Staff
|
||||
{
|
||||
FKUserId = userId,
|
||||
FKClientId = clientId,
|
||||
StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null,
|
||||
Department = data.ContainsKey("department") ? data["department"].GetString() : null,
|
||||
FKGymRoleId = gymRoleId,
|
||||
};
|
||||
|
||||
var result = await _staffRepository.CreateStaff(staff, user);
|
||||
|
||||
await _uow.SaveChangesAsync();
|
||||
await _accessDeviceService.EnsureAccessProfileAsync(
|
||||
clientId, user.UserId, "Staff", user.UserName ?? string.Empty);
|
||||
|
||||
AuthResponse authResponse;
|
||||
try
|
||||
{
|
||||
var authPayload = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6854567b41ed");
|
||||
Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId);
|
||||
authPayload["roleId"] = JsonSerializer.SerializeToElement(roleId);
|
||||
authPayload["userTypeId"] = JsonSerializer.SerializeToElement(userTypeId);
|
||||
// The client portal's request uses "phoneNumber", but Core-Hex-Auth's
|
||||
// RegisterUser only reads "mobileNumber" — without this the auth User row
|
||||
// was silently created with a null phone number.
|
||||
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}");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// Newly created staff have no password of their own yet — email them a
|
||||
// "set your password" link (reuses the auth service's reset-link flow).
|
||||
// Non-critical: a failure here must not roll back the created staff member.
|
||||
try
|
||||
{
|
||||
await _authService.ForgotPassword(
|
||||
new { identifier = user.Email, useResetLink = true, welcome = true },
|
||||
httpContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Staff set-password email failed (non-critical): {ex.Message}");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
staff = await MapStaffAsync(result, user),
|
||||
auth = authResponse
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateStaff(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("staffId"))
|
||||
{
|
||||
throw new KeyNotFoundException("staffId is required");
|
||||
}
|
||||
|
||||
var staffId = Guid.Parse(data["staffId"].GetString()!);
|
||||
|
||||
// Get existing staff first
|
||||
var existingStaff = await _staffRepository.GetStaffById(staffId);
|
||||
if (existingStaff == null)
|
||||
{
|
||||
throw new KeyNotFoundException("Staff not found");
|
||||
}
|
||||
var callerClientId = await _userRoleHelper.GetClientId(
|
||||
DataExtractor.GetUserIdFromContext(httpContext));
|
||||
if (existingStaff.FKClientId != callerClientId)
|
||||
throw new ForbiddenException("This staff member does not belong to your gym.");
|
||||
|
||||
// Create update object with only provided fields
|
||||
var updateStaff = new Models.Staff
|
||||
{
|
||||
StaffId = staffId,
|
||||
StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null,
|
||||
Department = data.ContainsKey("department") ? data["department"].GetString() : null,
|
||||
IsActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : existingStaff.IsActive,
|
||||
User = new User
|
||||
{
|
||||
UserName = data.ContainsKey("userName") ? data["userName"].GetString() : null,
|
||||
Email = data.ContainsKey("email") ? data["email"].GetString() : null,
|
||||
PhoneNumber = data.ContainsKey("phoneNumber") ? data["phoneNumber"].GetString() : null
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _staffRepository.UpdateStaff(updateStaff);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return await MapStaffAsync(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> DeleteStaff(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
if (!data!.ContainsKey("staffId"))
|
||||
{
|
||||
throw new KeyNotFoundException("staffId is required");
|
||||
}
|
||||
|
||||
var staffId = Guid.Parse(data["staffId"].GetString()!);
|
||||
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
|
||||
|
||||
// Check if hard delete is requested (default is soft delete)
|
||||
bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean();
|
||||
|
||||
var result = await _staffRepository.DeleteStaff(staffId, softDelete);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
throw new KeyNotFoundException("Staff not found");
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = softDelete
|
||||
? "Staff deactivated successfully"
|
||||
: "Staff deleted successfully",
|
||||
softDelete = softDelete
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetPayRate(Guid staffId, HttpContext httpContext)
|
||||
{
|
||||
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
|
||||
var rate = await _staffRepository.GetActivePayRateAsync(staffId);
|
||||
return rate == null
|
||||
? null!
|
||||
: new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom };
|
||||
}
|
||||
|
||||
public async Task<object> UpsertPayRate(Guid staffId, string rateType, decimal amount, HttpContext httpContext)
|
||||
{
|
||||
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
|
||||
|
||||
if (rateType != "Monthly" && rateType != "Hourly")
|
||||
throw new BadRequestException("rateType must be 'Monthly' or 'Hourly'.");
|
||||
if (amount <= 0)
|
||||
throw new BadRequestException("amount must be greater than zero.");
|
||||
|
||||
var currentRate = await _staffRepository.GetActivePayRateAsync(staffId);
|
||||
if (currentRate != null)
|
||||
currentRate.IsActive = false;
|
||||
|
||||
var rate = await _staffRepository.AddPayRateAsync(new Models.StaffPayRate
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKStaffId = staffId,
|
||||
RateType = rateType,
|
||||
Amount = amount,
|
||||
EffectiveFrom = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
});
|
||||
await _uow.SaveChangesAsync();
|
||||
return new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom };
|
||||
}
|
||||
|
||||
public async Task<object> GetPayments(Guid staffId, HttpContext httpContext)
|
||||
{
|
||||
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
|
||||
var payments = await _staffRepository.GetPaymentsAsync(staffId);
|
||||
return payments.Select(p => new StaffPaymentDto { Id = p.Id, Amount = p.Amount, Note = p.Note, PaidAt = p.PaidAt }).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> RecordPayment(Guid staffId, decimal amount, string? note, HttpContext httpContext)
|
||||
{
|
||||
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
|
||||
|
||||
if (amount <= 0)
|
||||
throw new BadRequestException("amount must be greater than zero.");
|
||||
|
||||
var payment = new Models.StaffPayment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKStaffId = staffId,
|
||||
Amount = amount,
|
||||
Note = note,
|
||||
PaidAt = DateTime.UtcNow,
|
||||
FKPaidByUserId = DataExtractor.GetUserIdFromContext(httpContext)
|
||||
};
|
||||
|
||||
await _staffRepository.AddPaymentAsync(payment);
|
||||
await _uow.SaveChangesAsync();
|
||||
return new StaffPaymentDto { Id = payment.Id, Amount = payment.Amount, Note = payment.Note, PaidAt = payment.PaidAt };
|
||||
}
|
||||
|
||||
private async Task EnsureStaffBelongsToCallerAsync(Guid staffId, HttpContext httpContext)
|
||||
{
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var clientId = await _userRoleHelper.GetClientId(userId);
|
||||
|
||||
var staff = await _staffRepository.GetStaffById(staffId)
|
||||
?? throw new KeyNotFoundException("Staff not found");
|
||||
|
||||
if (staff.FKClientId != clientId)
|
||||
throw new ForbiddenException("This staff member does not belong to your gym.");
|
||||
}
|
||||
|
||||
private async Task<StaffDto> MapStaffAsync(Models.Staff staff, User? userOverride = null)
|
||||
{
|
||||
var user = userOverride ?? staff.User;
|
||||
var access = await _accessDeviceService.GetAccessStatusAsync(
|
||||
staff.FKClientId, staff.FKUserId);
|
||||
return new StaffDto
|
||||
{
|
||||
StaffId = staff.StaffId,
|
||||
UserId = staff.FKUserId,
|
||||
UserName = user?.UserName,
|
||||
Email = user?.Email,
|
||||
PhoneNumber = user?.PhoneNumber,
|
||||
ClientId = staff.FKClientId,
|
||||
ClientName = staff.Client?.ClientName,
|
||||
GymRoleId = staff.FKGymRoleId,
|
||||
GymRoleName = staff.GymRole?.Name,
|
||||
StaffPosition = staff.StaffPosition,
|
||||
Department = staff.Department,
|
||||
HireDate = staff.HireDate,
|
||||
IsActive = staff.IsActive,
|
||||
CreatedAt = staff.CreatedAt,
|
||||
AccessProfileId = access?.AccessProfileId,
|
||||
AccessNumber = access?.AccessNumber,
|
||||
AccessProvisionStatus = access?.ProvisionStatus ?? "NotConfigured",
|
||||
ActualAccessState = access?.ActualAccessState ?? "Unknown",
|
||||
DesiredAccessState = access?.DesiredAccessState ?? "Enabled",
|
||||
FingerprintCount = access?.FingerprintCount ?? 0,
|
||||
AccessLastError = access?.LastError
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<StaffDto>> MapStaffListAsync(IEnumerable<Models.Staff> staff)
|
||||
{
|
||||
var result = new List<StaffDto>();
|
||||
foreach (var member in staff)
|
||||
result.Add(await MapStaffAsync(member));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using Fitness_core_BE.Domain;
|
||||
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 Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
// Staff-facing approval workflow for the unapproved (IsActive=false) invoices the daily
|
||||
// SubscriptionRenewalJob sweep creates -- approve/change-plan/reject are the only things
|
||||
// that ever advance CustomerSubscription.RenewalDate or replace a subscription's plan; the
|
||||
// daily sweep itself only ever creates unapproved invoices.
|
||||
public class SubscriptionRenewalApprovalService : ISubscriptionRenewalApprovalService
|
||||
{
|
||||
private readonly ICustomerInvoiceRepository _invoiceRepository;
|
||||
private readonly ICustomerSubscriptionRepository _subscriptionRepository;
|
||||
private readonly IClientRepository _clientRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly ILogger<SubscriptionRenewalApprovalService> _logger;
|
||||
|
||||
public SubscriptionRenewalApprovalService(
|
||||
ICustomerInvoiceRepository invoiceRepository,
|
||||
ICustomerSubscriptionRepository subscriptionRepository,
|
||||
IClientRepository clientRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper,
|
||||
ILogger<SubscriptionRenewalApprovalService> logger)
|
||||
{
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_subscriptionRepository = subscriptionRepository;
|
||||
_clientRepository = clientRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<object> GetUnapprovedInvoicesAsync(HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var invoices = await _invoiceRepository.GetUnapprovedInvoicesByClientIdAsync(clientId);
|
||||
|
||||
// How much of `amount` is this cycle's own charge vs. debt carried over from a prior
|
||||
// Pending/Partial invoice -- the approvals list shows the total already, but staff
|
||||
// reviewing a renewal need to see the breakdown before approving it.
|
||||
var carriedForward = await _invoiceRepository.GetCarriedForwardAmountsAsync(
|
||||
invoices.Select(i => i.CustomerInvoiceId).ToList());
|
||||
|
||||
return invoices.Select(i =>
|
||||
{
|
||||
carriedForward.TryGetValue(i.CustomerInvoiceId, out var carriedForwardAmount);
|
||||
return new
|
||||
{
|
||||
customerInvoiceId = i.CustomerInvoiceId,
|
||||
fkCustomerSubscriptionId = i.FKCustomerSubscriptionId,
|
||||
amount = i.Amount,
|
||||
carriedForwardAmount,
|
||||
status = i.Status,
|
||||
customerInvoiceDate = i.CustomerInvoiceDate,
|
||||
customerName = i.CustomerSubscription?.TenantCustomerLink?.DisplayName,
|
||||
planName = i.CustomerSubscription?.CustomerPlan?.PlanName,
|
||||
planPrice = i.CustomerSubscription?.CustomerPlan?.PlanPrice,
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> ApproveAsync(Guid customerInvoiceId, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
|
||||
var subscription = invoice.CustomerSubscription;
|
||||
var plan = subscription.CustomerPlan
|
||||
?? throw new BadRequestException("This subscription's plan could not be found.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Installments are keyed by subscription, not by invoice/cycle -- the prior
|
||||
// cycle's rows must be removed before this cycle's schedule is added (see
|
||||
// DeactivatePriorInstallmentsAsync for why this is a hard delete).
|
||||
await _subscriptionRepository.DeactivatePriorInstallmentsAsync(subscription.SubscriptionId);
|
||||
var installments = CustomerSubscriptionFactory.BuildInstallmentSchedule(
|
||||
subscription, plan, now, anchorDate: now);
|
||||
await _subscriptionRepository.AddInstallmentsAsync(installments);
|
||||
|
||||
invoice.IsActive = true;
|
||||
|
||||
// Advance from the OLD RenewalDate, not from "now" -- a late approval shouldn't
|
||||
// shift the cadence of future cycles.
|
||||
var newRenewalDate = (subscription.RenewalDate ?? now).AddDays(plan.DurationInDays);
|
||||
await _subscriptionRepository.AdvanceRenewalDateAsync(subscription.SubscriptionId, newRenewalDate);
|
||||
|
||||
// Session-count cycle reset (SessionsPerCycle plans only -- unlimited-access
|
||||
// plans have nothing to reset). Whatever was left unused this ending cycle either
|
||||
// rolls into the new one (GracePeriod, until RolloverGraceDays elapses) or is
|
||||
// forfeited (NoRollover) -- see the fields' own comments on CustomerPlan/
|
||||
// CustomerSubscription for the full reasoning.
|
||||
if (plan.SessionsPerCycle.HasValue)
|
||||
{
|
||||
var allowanceThisCycle = plan.SessionsPerCycle.Value + subscription.SessionsCarriedOver;
|
||||
var unusedThisCycle = Math.Max(0, allowanceThisCycle - subscription.SessionsUsedThisCycle);
|
||||
|
||||
if (plan.SessionRolloverPolicy == "GracePeriod" && unusedThisCycle > 0)
|
||||
{
|
||||
subscription.SessionsCarriedOver = unusedThisCycle;
|
||||
subscription.SessionsCarriedOverExpiresAt = now.AddDays(plan.RolloverGraceDays);
|
||||
}
|
||||
else
|
||||
{
|
||||
subscription.SessionsCarriedOver = 0;
|
||||
subscription.SessionsCarriedOverExpiresAt = null;
|
||||
}
|
||||
|
||||
subscription.SessionsUsedThisCycle = 0;
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync();
|
||||
await _uow.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalApproval: invoice {InvoiceId} approved for subscription {SubscriptionId}; RenewalDate advanced to {RenewalDate:u}.",
|
||||
customerInvoiceId, subscription.SubscriptionId, newRenewalDate);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Renewal approved",
|
||||
customerInvoiceId,
|
||||
subscriptionId = subscription.SubscriptionId,
|
||||
renewalDate = newRenewalDate
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> ChangePlanAsync(Guid customerInvoiceId, Guid newCustomerPlanId, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
|
||||
var oldSubscription = invoice.CustomerSubscription;
|
||||
|
||||
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(oldSubscription.FKCustomerId);
|
||||
|
||||
// Sibling namespace Fitness_core_BE.Services.CustomerSubscription
|
||||
// (CustomerSubscriptionService.cs) outranks a bare `CustomerSubscription` here.
|
||||
var newSubscription = new Fitness_core_BE.Models.CustomerSubscription
|
||||
{
|
||||
FKCustomerPlanId = newCustomerPlanId,
|
||||
FKCustomerId = oldSubscription.FKCustomerId,
|
||||
OnboardDate = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
var initialSubItem = new CustomerSubItem
|
||||
{
|
||||
SubItemName = "Membership change (renewal approval)",
|
||||
IsActive = true
|
||||
};
|
||||
var aggregate = CustomerSubscriptionFactory.Create(newSubscription, newPlan, initialSubItem);
|
||||
|
||||
await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate);
|
||||
// RepointAndRecalculateInvoiceAsync re-queries the new subscription by id, so it
|
||||
// must be persisted first.
|
||||
await _uow.SaveChangesAsync();
|
||||
|
||||
await _invoiceRepository.RepointAndRecalculateInvoiceAsync(customerInvoiceId, newSubscription.SubscriptionId);
|
||||
await _uow.SaveChangesAsync();
|
||||
await _uow.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalApproval: invoice {InvoiceId} approved via plan change -- subscription {OldSubscriptionId} replaced by {NewSubscriptionId} on plan {NewPlanId}.",
|
||||
customerInvoiceId, oldSubscription.SubscriptionId, newSubscription.SubscriptionId, newCustomerPlanId);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = "Plan changed and renewal approved",
|
||||
customerInvoiceId,
|
||||
subscriptionId = newSubscription.SubscriptionId
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> RejectAsync(Guid customerInvoiceId, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
|
||||
|
||||
// Already IsActive=false (that's what put it in the approval queue) -- Status
|
||||
// also needs a terminal value, otherwise it still reads as an outstanding invoice
|
||||
// everywhere that isn't IsActive-filtered (e.g. GetCustomerInvoicesByClientId).
|
||||
invoice.Status = "Cancelled";
|
||||
invoice.IsActive = false;
|
||||
|
||||
await _clientRepository.DeactivateSubscriptionAsync(invoice.FKCustomerSubscriptionId);
|
||||
|
||||
await _uow.SaveChangesAsync();
|
||||
await _uow.CommitAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalApproval: invoice {InvoiceId} rejected; subscription {SubscriptionId} deactivated.",
|
||||
customerInvoiceId, invoice.FKCustomerSubscriptionId);
|
||||
|
||||
return new { success = true, message = "Renewal rejected", customerInvoiceId };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs)
|
||||
// outranks a bare `CustomerInvoice` here, so the model type needs its full name.
|
||||
private async Task<Fitness_core_BE.Models.CustomerInvoice> LoadUnapprovedInvoiceForClientAsync(Guid customerInvoiceId, Guid clientId)
|
||||
{
|
||||
var invoice = await _invoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
|
||||
?? throw new NotFoundException("Invoice not found.");
|
||||
|
||||
if (invoice.FKClientId != clientId)
|
||||
throw new ForbiddenException("This invoice does not belong to your gym.");
|
||||
|
||||
if (invoice.IsActive)
|
||||
throw new BadRequestException("This invoice has already been approved or is not awaiting approval.");
|
||||
|
||||
return invoice;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Fitness_core_BE.Infra.Tenancy;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily sweep (01:00 Sri Lanka time) that checks every provisioned tenant schema for
|
||||
/// CustomerSubscriptions due for renewal (RenewalDate on or before today) and creates an
|
||||
/// unapproved (IsActive=false) renewal invoice for each -- see SubscriptionRenewalRunner for
|
||||
/// the actual per-subscription algorithm (Paid vs. Pending/Partial carry-forward). Nothing on
|
||||
/// the subscription itself changes here; approving/changing-plan/rejecting the invoice
|
||||
/// (SubscriptionRenewalApprovalService) is what advances RenewalDate or replaces the plan.
|
||||
/// Own, independent loop from OverdueLockJob's 6-hour cadence -- deliberately not merged with it.
|
||||
///
|
||||
/// Same as OverdueLockJob, this is a background service with no HttpContext, so it can't
|
||||
/// rely on TenantConnectionResolver's per-request tenant resolution -- it loops every
|
||||
/// provisioned tenant schema directly via TenantSchemaIterator instead.
|
||||
/// </summary>
|
||||
public class SubscriptionRenewalJob : BackgroundService
|
||||
{
|
||||
private const string JobName = "SubscriptionRenewalJob";
|
||||
|
||||
// 01:00 Sri Lanka local time (UTC+5:30, no DST observed since 2006) -- the gyms this
|
||||
// runs for are all Sri Lanka-based, so the trigger point is anchored to local time
|
||||
// rather than UTC. The actual RenewalDate/candidate comparisons inside
|
||||
// SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the
|
||||
// wake-up point shifts.
|
||||
private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1);
|
||||
private static readonly TimeZoneInfo SriLankaTimeZone = ResolveSriLankaTimeZone();
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SubscriptionRenewalJob> _logger;
|
||||
|
||||
public SubscriptionRenewalJob(IServiceScopeFactory scopeFactory, ILogger<SubscriptionRenewalJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = ComputeDelayUntilNextRun(DateTime.UtcNow);
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalJob: next run scheduled at {NextRunUtc:u} UTC (in {Delay}).",
|
||||
DateTime.UtcNow.Add(delay), delay);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Shutting down.
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("SubscriptionRenewalJob: daily run starting.");
|
||||
await RunOnceAsync(stoppingToken);
|
||||
_logger.LogInformation("SubscriptionRenewalJob: daily run finished.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SubscriptionRenewalJob run failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
||||
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
||||
// zone is a safe fallback rather than failing the whole job.
|
||||
return TimeZoneInfo.CreateCustomTimeZone(
|
||||
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
|
||||
// run just because the app happened to restart. Internal + static so it's unit-testable
|
||||
// without spinning up the whole hosted service.
|
||||
internal static TimeSpan ComputeDelayUntilNextRun(DateTime nowUtc)
|
||||
{
|
||||
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, SriLankaTimeZone);
|
||||
var todayRunLocal = nowLocal.Date + RunAtLocal;
|
||||
var nextRunLocal = nowLocal < todayRunLocal ? todayRunLocal : todayRunLocal.AddDays(1);
|
||||
var nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(
|
||||
DateTime.SpecifyKind(nextRunLocal, DateTimeKind.Unspecified), SriLankaTimeZone);
|
||||
return nextRunUtc - nowUtc;
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var iterator = scope.ServiceProvider.GetRequiredService<TenantSchemaIterator>();
|
||||
var runner = scope.ServiceProvider.GetRequiredService<SubscriptionRenewalRunner>();
|
||||
var history = scope.ServiceProvider.GetRequiredService<IJobRunHistory>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await foreach (var (clientId, schemaName) in iterator.GetProvisionedRegistrationsAsync(ct))
|
||||
{
|
||||
// Declared outside the try so the catch block can still use schemaCtx.Central to
|
||||
// record the failure -- central is one fixed database regardless of tenant, so
|
||||
// it's safe to reuse (or lazily open) here even though the schema-specific work failed.
|
||||
TenantSchemaContext? schemaCtx = null;
|
||||
try
|
||||
{
|
||||
schemaCtx = iterator.OpenSchema(clientId, schemaName);
|
||||
var result = await runner.RunForSchemaAsync(schemaCtx, now, ct);
|
||||
|
||||
await history.RecordAsync(schemaCtx.Central, new JobRunRecord(
|
||||
JobName, clientId, now,
|
||||
result.Failed > 0 ? "PartialFailure" : "Success",
|
||||
result.Renewed,
|
||||
result.Failed > 0 ? string.Join("; ", result.Notes) : null), ct);
|
||||
|
||||
if (result.Renewed > 0 || result.Failed > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalJob: client {ClientId} ({Schema}) -- {Candidates} candidate(s), {Renewed} renewed, {Failed} failed.",
|
||||
clientId, schemaName, result.Candidates, result.Renewed, result.Failed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SubscriptionRenewalJob failed for client {ClientId} schema {Schema}.", clientId, schemaName);
|
||||
schemaCtx ??= iterator.OpenSchema(clientId, schemaName);
|
||||
await history.RecordAsync(schemaCtx.Central, new JobRunRecord(JobName, clientId, now, "Failed", 0, ex.Message), ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (schemaCtx != null)
|
||||
await schemaCtx.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Fitness_core_BE.Infra.Tenancy;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Repos;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public sealed record SubscriptionRenewalRunResult(
|
||||
Guid ClientId, int Candidates, int Renewed, int Failed, IReadOnlyList<string> Notes);
|
||||
|
||||
// The per-schema renewal algorithm, extracted from SubscriptionRenewalJob's daily loop so
|
||||
// the admin "trigger now" endpoint can call the exact same code path instead of duplicating it.
|
||||
//
|
||||
// Renewal-approval model: the daily sweep never touches the CustomerSubscription itself
|
||||
// (IsActive/RenewalDate/members/installments) -- it only computes whether a new invoice is
|
||||
// due and creates it as unapproved (IsActive=false). Advancing RenewalDate, and everything
|
||||
// else that used to happen automatically (deactivate old subscription, create a new one),
|
||||
// now only happens when staff approve/change-plan/reject via SubscriptionRenewalApprovalService.
|
||||
public class SubscriptionRenewalRunner(ILogger<SubscriptionRenewalRunner> logger)
|
||||
{
|
||||
private readonly ILogger<SubscriptionRenewalRunner> _logger = logger;
|
||||
|
||||
public async Task<SubscriptionRenewalRunResult> RunForSchemaAsync(
|
||||
TenantSchemaContext schemaCtx, DateTime now, CancellationToken ct = default)
|
||||
{
|
||||
var tenant = schemaCtx.Tenant;
|
||||
var central = schemaCtx.Central;
|
||||
var today = now.Date;
|
||||
|
||||
// DurationMode == "Rolling" is a hard requirement, not a preference: FixedRange
|
||||
// plans are fixed-date, non-renewing by design. RenewalDate (not ExpiryDate) drives
|
||||
// eligibility -- ExpiryDate is frozen at creation and never advances. The "no
|
||||
// already-pending invoice" exclusion is what makes this idempotent: once a renewal
|
||||
// invoice exists unapproved, the subscription is skipped on every subsequent run
|
||||
// until an approval action (approve/change-plan/reject) resolves it.
|
||||
var candidates = await tenant.CustomerSubscriptions
|
||||
.Include(s => s.CustomerPlan)
|
||||
.Where(s => s.IsActive
|
||||
&& s.RenewalDate != null && s.RenewalDate < today.AddDays(1)
|
||||
&& s.CustomerPlan.DurationMode == "Rolling"
|
||||
&& s.CustomerPlan.IsActive
|
||||
&& !tenant.CustomerInvoices.Any(i =>
|
||||
i.FKCustomerSubscriptionId == s.SubscriptionId
|
||||
&& i.IsActive == false
|
||||
&& i.Status != "Cancelled"))
|
||||
.ToListAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalJob: client {ClientId} -- {Candidates} subscription(s) due for renewal.",
|
||||
schemaCtx.ClientId, candidates.Count);
|
||||
|
||||
var notes = new List<string>();
|
||||
int renewed = 0, failed = 0;
|
||||
|
||||
var uow = new EFUnitOfWork(central, tenant);
|
||||
var subscriptionRepo = new CustomerSubscriptionRepository(tenant, central);
|
||||
var invoiceRepo = new CustomerInvoiceRepository(tenant);
|
||||
|
||||
foreach (var oldSubscription in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RenewOneAsync(oldSubscription, tenant, uow, subscriptionRepo, invoiceRepo, _logger, ct);
|
||||
renewed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
// RollbackAsync only rolls back the DB transaction -- it does not clear EF's
|
||||
// change tracker. tenant/central are reused across every candidate in this
|
||||
// loop, so a failed candidate's tracked-but-unsaved mutations (e.g. the
|
||||
// "RolledOver" status flip) would otherwise still be flushed by the *next*
|
||||
// candidate's SaveChanges/CommitAsync.
|
||||
tenant.ChangeTracker.Clear();
|
||||
central.ChangeTracker.Clear();
|
||||
failed++;
|
||||
var note = $"Renewal failed for subscription {oldSubscription.SubscriptionId} (customer {oldSubscription.FKCustomerId}): {ex.Message}";
|
||||
notes.Add(note);
|
||||
_logger.LogError(ex, "SubscriptionRenewalJob: {Note}", note);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"SubscriptionRenewalJob: client {ClientId} done -- {Renewed} renewal invoice(s) created, {Failed} failed.",
|
||||
schemaCtx.ClientId, renewed, failed);
|
||||
|
||||
return new SubscriptionRenewalRunResult(schemaCtx.ClientId, candidates.Count, renewed, failed, notes);
|
||||
}
|
||||
|
||||
private static async Task RenewOneAsync(
|
||||
Fitness_core_BE.Models.CustomerSubscription oldSubscription,
|
||||
TenantDbContext tenant,
|
||||
EFUnitOfWork uow,
|
||||
CustomerSubscriptionRepository subscriptionRepo,
|
||||
CustomerInvoiceRepository invoiceRepo,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
|
||||
// By construction this is still the real, currently-collectible invoice: the
|
||||
// candidate query above already excludes subscriptions with a pending-approval
|
||||
// invoice, and nothing else sets CustomerInvoice.IsActive=false.
|
||||
var oldInvoice = await subscriptionRepo.GetLatestInvoiceAsync(oldSubscription.SubscriptionId, ct);
|
||||
if (oldInvoice == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Subscription {oldSubscription.SubscriptionId} has no invoice to renew from.");
|
||||
}
|
||||
|
||||
decimal carriedOverBalance;
|
||||
switch (oldInvoice.Status)
|
||||
{
|
||||
case "Paid":
|
||||
carriedOverBalance = 0m;
|
||||
logger.LogInformation(
|
||||
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was Paid, no balance carried forward.",
|
||||
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId);
|
||||
break;
|
||||
|
||||
case "Pending":
|
||||
case "Partial":
|
||||
var approvedPaid = await subscriptionRepo.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId, ct);
|
||||
carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid);
|
||||
|
||||
// GetLatestInvoiceAsync reads AsNoTracking -- re-fetch tracked to mutate it.
|
||||
var trackedOldInvoice = await tenant.CustomerInvoices.FindAsync(
|
||||
new object[] { oldInvoice.CustomerInvoiceId }, ct);
|
||||
trackedOldInvoice!.Status = "RolledOver";
|
||||
|
||||
logger.LogInformation(
|
||||
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was {OldStatus}, rolling over unpaid balance {CarriedOverBalance} and marking it RolledOver.",
|
||||
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId, oldInvoice.Status, carriedOverBalance);
|
||||
break;
|
||||
|
||||
default:
|
||||
// "Failed"/"Refunded" (or a stray "Cancelled"/"RolledOver" the candidate
|
||||
// query's own invoice-state shouldn't produce) -- don't guess how to carry
|
||||
// these forward; surfaces as a PartialFailure note for manual review.
|
||||
throw new InvalidOperationException(
|
||||
$"Subscription {oldSubscription.SubscriptionId}'s current invoice has unexpected status '{oldInvoice.Status}'.");
|
||||
}
|
||||
|
||||
// Renewal invoices skip the onboarding fee (one-time, not a per-cycle charge) and
|
||||
// start unapproved (IsActive=false) -- staff must approve/change-plan/reject before
|
||||
// it counts. The subscription itself (IsActive, RenewalDate, members, installments)
|
||||
// is untouched here; all of that is deferred to the approval action.
|
||||
var newInvoice = await invoiceRepo.CreateRenewalInvoiceAsync(oldSubscription.SubscriptionId, carriedOverBalance, ct);
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"SubscriptionRenewalJob: subscription {SubscriptionId} -- created unapproved renewal invoice {InvoiceId} (amount {Amount}), awaiting staff approval.",
|
||||
oldSubscription.SubscriptionId, newInvoice.CustomerInvoiceId, newInvoice.Amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Combines the two outbound notification channels — SMS (Introps) and email (Gmail SMTP) —
|
||||
/// into one service, matching the pattern already used by Core-Hex-Auth's ThirdPartyService.
|
||||
/// </summary>
|
||||
public class ThirdPartyService : IThirdPartyService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
// Email (Gmail SMTP). Use a Gmail App Password, not the account password.
|
||||
private readonly string _gmailUser;
|
||||
private readonly string _gmailPassword;
|
||||
private readonly string _gmailHost;
|
||||
private readonly int _gmailPort;
|
||||
private readonly string _gmailFromName;
|
||||
|
||||
public ThirdPartyService(IHttpClientFactory httpClientFactory, IConfiguration config)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_config = config;
|
||||
|
||||
_gmailUser = Environment.GetEnvironmentVariable("GMAIL_USER") ?? config["Gmail:User"] ?? "";
|
||||
_gmailPassword = Environment.GetEnvironmentVariable("GMAIL_APP_PASSWORD") ?? config["Gmail:AppPassword"] ?? "";
|
||||
_gmailHost = config["Gmail:Host"] ?? "smtp.gmail.com";
|
||||
_gmailPort = int.TryParse(config["Gmail:Port"], out var p) ? p : 587;
|
||||
_gmailFromName = config["Gmail:FromName"] ?? "GYM OS";
|
||||
}
|
||||
|
||||
// ---------- SMS ----------
|
||||
|
||||
public async Task<OtpResponse> SendSmsAsync(string mobileNumber, string? otpCode = null, string? message = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mobileNumber))
|
||||
{
|
||||
return new OtpResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = "Mobile number is required"
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(otpCode) && string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return new OtpResponse
|
||||
{
|
||||
StatusCode = 400,
|
||||
Message = "Either an OTP code or a message is required"
|
||||
};
|
||||
}
|
||||
|
||||
string contactNumber = mobileNumber;
|
||||
string apiUrl = _config["Sms:ApiUrl"] ?? "https://backup.introps.com/Sms_common/send_sms";
|
||||
string apiKey = Environment.GetEnvironmentVariable("SMS_API_KEY") ?? _config["Sms:ApiKey"] ?? "8989898989";
|
||||
|
||||
var msgText = string.IsNullOrWhiteSpace(message) ? $"Your otp code is: {otpCode}" : message;
|
||||
|
||||
var smsRequest = new SmsRequest
|
||||
{
|
||||
api_key = apiKey,
|
||||
phone_number = contactNumber,
|
||||
msg_text = msgText
|
||||
};
|
||||
|
||||
var formContent = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("api_key", smsRequest.api_key),
|
||||
new KeyValuePair<string, string>("phone_number", smsRequest.phone_number),
|
||||
new KeyValuePair<string, string>("msg_text", smsRequest.msg_text)
|
||||
});
|
||||
|
||||
var response = await _httpClient.PostAsync(apiUrl, formContent);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (response.IsSuccessStatusCode && responseContent.Contains("agent:d"))
|
||||
{
|
||||
string jsonString = responseContent.Split(new[] { "agent:d" }, StringSplitOptions.None)[1];
|
||||
|
||||
var responseJson = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonString);
|
||||
|
||||
if (responseJson is not null &&
|
||||
responseJson.TryGetValue("stt", out var statusValue) &&
|
||||
statusValue?.ToString() == "ok")
|
||||
{
|
||||
Console.WriteLine($"SMS sent to {contactNumber}");
|
||||
return new OtpResponse
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "SMS sent successfully."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Failed to send SMS to {contactNumber}. Response: {responseContent}");
|
||||
return new OtpResponse
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = "Failed to send SMS.",
|
||||
ApiResponse = responseContent
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Exception sending SMS: {ex.Message}");
|
||||
|
||||
return new OtpResponse
|
||||
{
|
||||
StatusCode = 500,
|
||||
Message = "Exception occurred while sending SMS.",
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Email ----------
|
||||
|
||||
public async Task SendEmailAsync(string toEmail, string subject, string htmlBody, byte[]? attachment = null, string? attachmentName = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_gmailUser) || string.IsNullOrWhiteSpace(_gmailPassword))
|
||||
throw new InvalidOperationException("Gmail SMTP is not configured (set GMAIL_USER / GMAIL_APP_PASSWORD).");
|
||||
|
||||
using var mail = new MailMessage
|
||||
{
|
||||
From = new MailAddress(_gmailUser, _gmailFromName),
|
||||
Subject = subject,
|
||||
Body = htmlBody,
|
||||
IsBodyHtml = true
|
||||
};
|
||||
mail.To.Add(toEmail);
|
||||
|
||||
MemoryStream? attachmentStream = null;
|
||||
if (attachment != null)
|
||||
{
|
||||
attachmentStream = new MemoryStream(attachment);
|
||||
mail.Attachments.Add(new Attachment(attachmentStream, attachmentName ?? "receipt.pdf", "application/pdf"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient(_gmailHost, _gmailPort)
|
||||
{
|
||||
EnableSsl = true,
|
||||
Credentials = new NetworkCredential(_gmailUser, _gmailPassword)
|
||||
};
|
||||
await client.SendMailAsync(mail);
|
||||
}
|
||||
finally
|
||||
{
|
||||
attachmentStream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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 System.Net.Sockets;
|
||||
|
||||
namespace Fitness_core_BE.Services.Ticket
|
||||
{
|
||||
public class TicketService : ITicketService
|
||||
{
|
||||
private readonly ITicketRepository _repository;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IActivityLogService _activityLogService;
|
||||
|
||||
public TicketService(ITicketRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService)
|
||||
{
|
||||
_repository = repository;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
_uow = uow;
|
||||
_activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
public async Task<object> GetTicketList(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tickets = await _repository.GetTicketListAsync();
|
||||
return tickets;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetTicketByUserId(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
|
||||
// ? parsedUserId
|
||||
// : throw new UnauthorizedAccessException("User ID not found in context");
|
||||
// Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
var tickets = await _repository.GetTicketByUserIdAsync(userId);
|
||||
return tickets;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetTicketById(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId);
|
||||
|
||||
if (ticket == null)
|
||||
throw new KeyNotFoundException($"Ticket with ID {ticketId} not found");
|
||||
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
|
||||
throw new UnauthorizedAccessException("You do not have permission to view this ticket.");
|
||||
|
||||
return ticket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetTicketCategoryList(object payload, HttpContext httpContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var categories = await _repository.GetTicketCategoryListAsync();
|
||||
return categories;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CreateTicket(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
// Parse userId from context
|
||||
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
|
||||
// ? parsedUserId
|
||||
// : throw new UnauthorizedAccessException("User ID not found in context");
|
||||
// Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
var ticket = new Fitness_core_BE.Models.Ticket
|
||||
{
|
||||
MainReason = data["mainReason"].GetString()!,
|
||||
FKTicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString())
|
||||
? Guid.Parse(data["ticketCategoryId"].GetString()!)
|
||||
: (Guid?)null,
|
||||
TicketStatus = data.ContainsKey("ticketStatus") && !string.IsNullOrEmpty(data["ticketStatus"].GetString())
|
||||
? Enum.Parse<Fitness_core_BE.Models.Enums.TicketStatusEnum>(data["ticketStatus"].GetString()!)
|
||||
: Fitness_core_BE.Models.Enums.TicketStatusEnum.Pending,
|
||||
FKUserId = userId,
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _repository.CreateTicketAsync(ticket);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
|
||||
// Log activity - non-blocking, failures won't crash the operation
|
||||
try
|
||||
{
|
||||
await _activityLogService.LogActivity(new { description = $"Ticket created with ID {ticket.TicketId}" }, httpContext);
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateTicket(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
if (ticket.UserId != userId)
|
||||
throw new UnauthorizedAccessException("You do not have permission to update this ticket.");
|
||||
|
||||
ticket.MainReason = data.ContainsKey("mainReason") && !string.IsNullOrEmpty(data["mainReason"].GetString())
|
||||
? data["mainReason"].GetString()!
|
||||
: ticket.MainReason;
|
||||
ticket.TicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString())
|
||||
? Guid.Parse(data["ticketCategoryId"].GetString()!)
|
||||
: ticket.TicketCategoryId;
|
||||
|
||||
await _repository.UpdateTicketAsync(ticket);
|
||||
await _uow.CommitAsync();
|
||||
return ticket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SendMessage(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
// Parse userId from context
|
||||
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
|
||||
// ? parsedUserId
|
||||
// : throw new UnauthorizedAccessException("User ID not found in context");
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
|
||||
Guid? adminId = await _userRoleHelper.GetAdminId(userId);
|
||||
bool isAdmin = adminId.HasValue;
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId)
|
||||
?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
|
||||
if (!isAdmin && ticket.UserId != userId)
|
||||
throw new UnauthorizedAccessException("You do not have permission to message this ticket.");
|
||||
|
||||
var ticketMessage = new TicketMessage
|
||||
{
|
||||
Message = data["message"].GetString()!,
|
||||
FKTicketId = ticketId,
|
||||
IsSentByAdmin = isAdmin,
|
||||
AttachmentURL = data.ContainsKey("attachmentURL") ? data["attachmentURL"].GetString() : null,
|
||||
FKDocumentId = DataExtractor.GetNullableInt(data, "documentId"),
|
||||
CreateDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// First admin reply moves a ticket from Pending to Open — nothing previously
|
||||
// set this transition, so tickets sat in Pending forever once created.
|
||||
if (isAdmin)
|
||||
{
|
||||
if (ticket.TicketStatus == Models.Enums.TicketStatusEnum.Pending)
|
||||
{
|
||||
await _repository.UpdateTicketStatusAsync(ticketId, Models.Enums.TicketStatusEnum.Open);
|
||||
}
|
||||
}
|
||||
|
||||
//Guid ticCatId = DataExtractor.GetRequiredGuid(data, "ticketCategoryId");
|
||||
|
||||
//Guid? TcatId = await _repository.GetTicketCatId("Chat");
|
||||
|
||||
//if (ticCatId == TcatId)
|
||||
//{
|
||||
|
||||
//}
|
||||
|
||||
await _repository.SendMessageAsync(ticketMessage);
|
||||
await _uow.CommitAsync();
|
||||
return ticketMessage;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SubmitTicketFeedback(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
int rating = DataExtractor.GetRequiredInt(data, "rating");
|
||||
string? feedback = data.TryGetValue("feedback", out var feedbackContent) && !string.IsNullOrEmpty(feedbackContent.GetString())
|
||||
? DataExtractor.GetOptionalString(data, "feedback")
|
||||
: null;
|
||||
|
||||
if (rating < 1 || rating > 5)
|
||||
throw new ArgumentException("Rating must be between 1 and 5.");
|
||||
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
|
||||
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
|
||||
throw new UnauthorizedAccessException("You do not have permission to submit feedback for this ticket.");
|
||||
if (ticket.TicketStatus != Models.Enums.TicketStatusEnum.Closed)
|
||||
throw new InvalidOperationException("Feedback can only be submitted after the ticket is closed.");
|
||||
|
||||
await _repository.SubmitTicketFeedbackAsync(ticketId, rating, feedback);
|
||||
await _uow.CommitAsync();
|
||||
return new { message = $"The feedback for ticket with ID {ticketId} submitted successfully." };
|
||||
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> CloseTicket(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId)
|
||||
?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
|
||||
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
|
||||
throw new UnauthorizedAccessException("You do not have permission to close this ticket.");
|
||||
|
||||
await _repository.UpdateTicketStatusAsync(ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum.Closed);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var updatedTicket = await _repository.GetByTicketIdAsync(ticketId);
|
||||
|
||||
if (updatedTicket == null)
|
||||
throw new KeyNotFoundException($"Ticket with ID {ticketId} not found after update");
|
||||
|
||||
return updatedTicket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> DeleteTicket(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
|
||||
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
|
||||
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
|
||||
|
||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
if (ticket.UserId != userId)
|
||||
throw new UnauthorizedAccessException("You do not have permission to delete this ticket.");
|
||||
|
||||
await _repository.DeleteAsync(ticketId);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
var deletedTicket = await _repository.GetByTicketIdAsync(ticketId);
|
||||
|
||||
if (deletedTicket == null)
|
||||
return true;
|
||||
|
||||
throw new ArgumentException($"Ticket with ID {ticketId} has not been deleted");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Fitness_core_BE.DTOs.TimeBankCard;
|
||||
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 System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class TimeBankCardService : ITimeBankCardService
|
||||
{
|
||||
private readonly ITimeBankCardRepository _timeBankCardRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public TimeBankCardService(ITimeBankCardRepository timeBankCardRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_timeBankCardRepository = timeBankCardRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> IssueCard(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
|
||||
var totalMinutes = data["totalMinutesPurchased"].GetInt32();
|
||||
if (totalMinutes < 1) throw new ArgumentException("totalMinutesPurchased must be at least 1");
|
||||
|
||||
if (await _timeBankCardRepository.GetByRfidTagId(rfidTagId) != null)
|
||||
throw new ArgumentException("This RFID tag already has an active time-bank card");
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var card = new TimeBankCard
|
||||
{
|
||||
FKClientId = clientId,
|
||||
RfidTagId = rfidTagId,
|
||||
TotalMinutesPurchased = totalMinutes,
|
||||
MinutesRemaining = totalMinutes,
|
||||
AmountPaid = data.ContainsKey("amountPaid") ? data["amountPaid"].GetDecimal() : 0m,
|
||||
FKCustomerId = data.ContainsKey("customerId") && data["customerId"].ValueKind != JsonValueKind.Null
|
||||
? Guid.Parse(data["customerId"].GetString()!)
|
||||
: null,
|
||||
ExpiresAt = data.ContainsKey("expiresAt") && data["expiresAt"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["expiresAt"].GetString()!).ToUniversalTime()
|
||||
: null
|
||||
};
|
||||
|
||||
var result = await _timeBankCardRepository.CreateTimeBankCard(card);
|
||||
await _uow.CommitAsync();
|
||||
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> Swipe(object payload)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
|
||||
var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId)
|
||||
?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag");
|
||||
|
||||
if (card.ExpiresAt.HasValue && card.ExpiresAt.Value < DateTime.UtcNow)
|
||||
throw new ArgumentException("This card has expired");
|
||||
|
||||
int? minutesDeducted = null;
|
||||
|
||||
if (!card.ActiveSessionStartedAt.HasValue)
|
||||
{
|
||||
// Tap-in: just open the session. Reject outright if there's no balance left
|
||||
// at all, so a spent card doesn't get to "start the clock" on a visit it
|
||||
// can never pay for.
|
||||
if (card.MinutesRemaining < 1)
|
||||
throw new ArgumentException("This card has no time remaining");
|
||||
|
||||
card.ActiveSessionStartedAt = DateTime.UtcNow;
|
||||
card.ActiveSessionFacilityId = facilityId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Tap-out: bill for actual elapsed time, rounded up to the next whole minute
|
||||
// (a 61-second visit still costs 2 minutes, not 1) -- capped at whatever
|
||||
// balance remains rather than letting a forgotten tap-out run the balance
|
||||
// negative.
|
||||
var elapsed = DateTime.UtcNow - card.ActiveSessionStartedAt.Value;
|
||||
var elapsedMinutes = Math.Max(1, (int)Math.Ceiling(elapsed.TotalMinutes));
|
||||
minutesDeducted = Math.Min(elapsedMinutes, card.MinutesRemaining);
|
||||
|
||||
card.MinutesRemaining -= minutesDeducted.Value;
|
||||
|
||||
await _timeBankCardRepository.AddUsageLog(new TimeBankCardUsageLog
|
||||
{
|
||||
FKTimeBankCardId = card.TimeBankCardId,
|
||||
FKClientId = card.FKClientId,
|
||||
FKFacilityId = card.ActiveSessionFacilityId ?? facilityId,
|
||||
MinutesDeducted = minutesDeducted.Value,
|
||||
MinutesRemainingAfter = card.MinutesRemaining
|
||||
});
|
||||
|
||||
card.ActiveSessionStartedAt = null;
|
||||
card.ActiveSessionFacilityId = null;
|
||||
}
|
||||
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(card, minutesDeducted);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetBalance(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
|
||||
|
||||
var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId)
|
||||
?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag");
|
||||
|
||||
return MapToDto(card);
|
||||
}
|
||||
|
||||
public async Task<object> GetMyCards(object payload, HttpContext httpContext)
|
||||
{
|
||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var cards = await _timeBankCardRepository.GetByCustomerId(cusId);
|
||||
return cards.Select(c => MapToDto(c)).ToList();
|
||||
}
|
||||
|
||||
private static TimeBankCardDto MapToDto(TimeBankCard card, int? minutesDeductedThisSwipe = null) => new()
|
||||
{
|
||||
TimeBankCardId = card.TimeBankCardId,
|
||||
RfidTagId = card.RfidTagId,
|
||||
CustomerId = card.FKCustomerId,
|
||||
TotalMinutesPurchased = card.TotalMinutesPurchased,
|
||||
MinutesRemaining = card.MinutesRemaining,
|
||||
AmountPaid = card.AmountPaid,
|
||||
PurchasedAt = card.PurchasedAt,
|
||||
ExpiresAt = card.ExpiresAt,
|
||||
IsActive = card.IsActive,
|
||||
SessionOpen = card.ActiveSessionStartedAt.HasValue,
|
||||
SessionStartedAt = card.ActiveSessionStartedAt,
|
||||
SessionFacilityName = card.ActiveSessionFacility?.Name,
|
||||
MinutesDeductedThisSwipe = minutesDeductedThisSwipe
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user