using Fitness_core_BE.DTOs.Stats; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Models; using Microsoft.EntityFrameworkCore; namespace Fitness_core_BE.Repos { public class ClientStatRepository : IClientStatRepository { private readonly TenantDbContext _dbContext; public ClientStatRepository(TenantDbContext dbContext) { _dbContext = dbContext; } // Member counts are driven by SubscriptionMembers so group secondaries // (couple/family) are counted, not just subscription primaries. private IQueryable ActiveMembersForClient(Guid clientId) { return _dbContext.SubscriptionMembers .Where(m => m.Subscription.CustomerPlan.FKClientId == clientId && m.Subscription.IsActive && m.IsActive && m.TenantCustomerLink.IsActiveAtThisClient); } public async Task GetTotalCustomerCountAsync(Guid clientId, CancellationToken ct = default) { return await ActiveMembersForClient(clientId) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); } public async Task GetMonthlyCustomerCountAsync(Guid clientId, CancellationToken ct = default) { var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); return await ActiveMembersForClient(clientId) .Where(m => m.JoinedAt >= startOfMonth) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); } public async Task GetTotalWorkoutPlanCountAsync(Guid clientId, CancellationToken ct = default) { return await _dbContext.WorkoutPlans .Where(w => w.FKClientId == clientId && w.IsActive) .CountAsync(ct); } public async Task GetTotalMealPlanCountAsync(Guid clientId, CancellationToken ct = default) { return await _dbContext.MealPlans .Where(m => m.FKClientId == clientId && m.IsActive) .CountAsync(ct); } public async Task GetLastMonthCustomerCountAsync(Guid clientId, CancellationToken ct = default) { var now = DateTime.UtcNow; var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1); var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); return await ActiveMembersForClient(clientId) .Where(m => m.JoinedAt >= startOfLastMonth && m.JoinedAt < startOfThisMonth) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); } public async Task> GetLastFiveAttendancesAsync(Guid clientId, CancellationToken ct = default) { return await _dbContext.Attendances .Where(a => a.FKClientId == clientId) .Include(a => a.TenantCustomerLink) .OrderByDescending(a => a.EventTime) .Take(5) .AsNoTracking() .ToListAsync(ct); } public async Task> GetLastFivePaymentsAsync(Guid clientId, CancellationToken ct = default) { var payments = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true) .Include(p => p.CustomerInvoice) .ThenInclude(ci => ci.CustomerSubscription) .ThenInclude(cs => cs.TenantCustomerLink) .OrderByDescending(p => p.PayDate) .Take(5) .AsNoTracking() .ToListAsync(ct); // PaymentTransaction.Customer isn't a real relationship post-split (Customer // identity lives in the central DB, this table is tenant-scoped) -- backfill a // display-only Customer shape from the cached TenantCustomerLink so this response's // existing customer.cusName shape keeps working for API consumers. foreach (var payment in payments) { var link = payment.CustomerInvoice?.CustomerSubscription?.TenantCustomerLink; if (link != null) { payment.Customer = new Customer { CusId = link.TenantCustomerLinkId, CusName = link.DisplayName, PrimaryPH = link.PrimaryPhoneCached, Email = link.EmailCached }; } } return payments; } // Revenue counts only approved payments (owner-recorded payments are approved // on creation; pending online attempts are excluded). public async Task GetLastMonthRevenueAsync(Guid clientId, CancellationToken ct = default) { var now = DateTime.UtcNow; var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1); var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); return await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true && p.PayDate >= startOfLastMonth && p.PayDate < startOfThisMonth) .SumAsync(p => p.PayAmount, ct); } public async Task> GetMonthlyRevenueVariationAsync( Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default) { DateTime rangeStart; DateTime rangeEndExclusive; bool byHour; bool byDay; if (from.HasValue && to.HasValue) { rangeStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc); rangeEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc); var totalDays = (rangeEndExclusive - rangeStart).TotalDays; // A single-day range (Today/Yesterday) would otherwise collapse to one bucket, // so break it down by hour instead so the chart still shows a trend. byHour = totalDays <= 1; byDay = !byHour && totalDays <= 31; } else { // Default (no explicit range): trailing 10 calendar months, unchanged from before. var now = DateTime.UtcNow; rangeStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-9); rangeEndExclusive = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1); byHour = false; byDay = false; } DateTime BucketKey(DateTime d) => byHour ? new DateTime(d.Year, d.Month, d.Day, d.Hour, 0, 0, DateTimeKind.Utc) : byDay ? d.Date : new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc); var payments = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true && p.PayDate >= rangeStart && p.PayDate < rangeEndExclusive) .Select(p => new { p.PayDate, p.PayAmount }) .AsNoTracking() .ToListAsync(ct); var newMembers = await ActiveMembersForClient(clientId) .Where(m => m.JoinedAt >= rangeStart && m.JoinedAt < rangeEndExclusive) .Select(m => new { m.JoinedAt, m.FKCustomerId }) .AsNoTracking() .ToListAsync(ct); var revenueByBucket = payments .GroupBy(p => BucketKey(p.PayDate!.Value)) .ToDictionary(g => g.Key, g => g.Sum(p => p.PayAmount)); var newMembersByBucket = newMembers .GroupBy(m => BucketKey(m.JoinedAt)) .ToDictionary(g => g.Key, g => g.Select(m => m.FKCustomerId).Distinct().Count()); var buckets = new List(); for (var d = rangeStart; d < rangeEndExclusive; d = byHour ? d.AddHours(1) : byDay ? d.AddDays(1) : d.AddMonths(1)) buckets.Add(d); return buckets .Select(bucket => new MonthlyRevenueStat { Year = bucket.Year, Month = bucket.Month, MonthLabel = byHour ? bucket.ToString("h tt") : byDay ? bucket.ToString("MMM d") : bucket.ToString("MMM yyyy"), Revenue = revenueByBucket.TryGetValue(bucket, out var rev) ? rev : 0m, NewMemberCount = newMembersByBucket.TryGetValue(bucket, out var cnt) ? cnt : 0 }) .ToList(); } public async Task GetExpiringSoonCountAsync(Guid clientId, DateTime from, DateTime to, CancellationToken ct = default) { return await ActiveMembersForClient(clientId) .Where(m => m.Subscription.RenewalDate != null && m.Subscription.RenewalDate >= from && m.Subscription.RenewalDate <= to) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); } public async Task GetTotalRevenueAsync(Guid clientId, CancellationToken ct = default) { return await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true) .SumAsync(p => p.PayAmount, ct); } public async Task GetClientStatSummaryAsync( Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default) { DateTime periodStart; DateTime periodEndExclusive; DateTime comparisonStart; if (from.HasValue && to.HasValue) { periodStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc); periodEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc); // Equal-length window immediately preceding the selected period, for growth %. comparisonStart = periodStart - (periodEndExclusive - periodStart); } else { var now = DateTime.UtcNow; periodStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); periodEndExclusive = periodStart.AddMonths(1); comparisonStart = periodStart.AddMonths(-1); } int totalCustomers = await ActiveMembersForClient(clientId) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); int thisPeriodCustomers = await ActiveMembersForClient(clientId) .Where(m => m.JoinedAt >= periodStart && m.JoinedAt < periodEndExclusive) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); int lastPeriodCustomers = await ActiveMembersForClient(clientId) .Where(m => m.JoinedAt >= comparisonStart && m.JoinedAt < periodStart) .Select(m => m.FKCustomerId) .Distinct() .CountAsync(ct); decimal thisPeriodRevenue = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true && p.PayDate >= periodStart && p.PayDate < periodEndExclusive) .SumAsync(p => p.PayAmount, ct); decimal lastPeriodRevenue = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true && p.PayDate >= comparisonStart && p.PayDate < periodStart) .SumAsync(p => p.PayAmount, ct); decimal totalRevenue = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true) .SumAsync(p => p.PayAmount, ct); decimal customerGrowthPct = lastPeriodCustomers == 0 ? (thisPeriodCustomers > 0 ? 100m : 0m) : Math.Round((decimal)(thisPeriodCustomers - lastPeriodCustomers) / lastPeriodCustomers * 100, 2); decimal revenueGrowthPct = lastPeriodRevenue == 0 ? (thisPeriodRevenue > 0 ? 100m : 0m) : Math.Round((thisPeriodRevenue - lastPeriodRevenue) / lastPeriodRevenue * 100, 2); // OverdueCount is filled in by ClientStatService (needs IOverdueLockRepository). var expiryWindowStart = DateTime.UtcNow; int expiringSoon = await GetExpiringSoonCountAsync(clientId, expiryWindowStart, expiryWindowStart.AddDays(7), ct); // "Today" always means today regardless of the from/to period filter above -- // matches the AttendanceRepository convention (DateTime.UtcNow.Date), not the // selected reporting window. var todayStart = DateTime.UtcNow.Date; var todayEndExclusive = todayStart.AddDays(1); var todayPayments = await _dbContext.PaymentTransactions .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true && p.PayDate >= todayStart && p.PayDate < todayEndExclusive) .ToListAsync(ct); return new ClientStatSummaryDto { TotalCustomerCount = totalCustomers, ThisMonthCustomerCount = thisPeriodCustomers, LastMonthCustomerCount = lastPeriodCustomers, CustomerGrowthPercentage = customerGrowthPct, ThisMonthRevenue = thisPeriodRevenue, LastMonthRevenue = lastPeriodRevenue, RevenueGrowthPercentage = revenueGrowthPct, TotalRevenue = totalRevenue, ExpiringWithin7DaysCount = expiringSoon, TodayPaymentsCount = todayPayments.Count, TodayPaymentsTotal = todayPayments.Sum(p => p.PayAmount) }; } } }