using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Infrastructure.Services; /// /// Manages subscription-based feature gating and resource-limit enforcement for tenant companies. /// All limit checks follow a consistent priority order: /// /// Comped companies (Company.IsComped == true) bypass every limit /// and are always treated as having unlimited allowances — useful for demo accounts, /// partners, and internal testing. /// Per-company overrides (Company.Max*Override) take precedence /// over plan defaults, enabling SuperAdmins to grant exceptions without changing the plan. /// provides the plan-level defaults /// looked up by Company.SubscriptionPlan. /// Hard-coded fallbacks in each method guard against missing plan config rows. /// /// All count queries use IgnoreQueryFilters() to include soft-deleted records where /// relevant, preventing a company from circumventing limits by deleting and re-adding records. /// public class SubscriptionService : ISubscriptionService { private readonly IUnitOfWork _unitOfWork; private readonly ApplicationDbContext _context; private readonly IPlatformSettingsService _platformSettings; public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context, IPlatformSettingsService platformSettings) { _unitOfWork = unitOfWork; _context = context; _platformSettings = platformSettings; } /// /// Returns true if the company can add another active user account. /// Unlimited is indicated by /// (-1) which short-circuits the count check. /// public async Task CanAddUserAsync(int companyId) { var (used, max) = await GetUserCountAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns true if the company can create another active (non-terminal) job. /// Terminal statuses are Completed, Delivered, and Cancelled — jobs in those states /// do not count against the active-job limit because they no longer consume shop capacity. /// public async Task CanAddJobAsync(int companyId) { var (used, max) = await GetJobCountAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns true if the company can add another customer record. /// The count includes soft-deleted customers (IgnoreQueryFilters) to prevent /// the limit from being gamed by deleting and re-creating customers. /// public async Task CanAddCustomerAsync(int companyId) { var (used, max) = await GetCustomerCountAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns true if the company can create another quote this calendar month. /// Quote limits are intentionally per-calendar-month (not per-billing-period) so that /// the reset is predictable for end users regardless of when their subscription renews. /// public async Task CanAddQuoteAsync(int companyId) { var (used, max) = await GetQuoteCountAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns true if the company can add another catalog service item. /// public async Task CanAddCatalogItemAsync(int companyId) { var (used, max) = await GetCatalogItemCountAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns the current active user count and the plan maximum for the company. /// Comped companies always return (0, Unlimited) to short-circuit all UI limit displays. /// The per-company override (MaxUsersOverride) is checked before the plan config, /// letting SuperAdmins grant a higher seat count without upgrading the plan. /// Falls back to 3 when neither override nor plan config is present. /// public async Task<(int Used, int Max)> GetUserCountAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var used = await _context.Users .Where(u => u.CompanyId == companyId && u.IsActive) .CountAsync(); var max = company?.MaxUsersOverride ?? config?.MaxUsers ?? 3; return (used, max); } /// /// Returns the active job count and the plan maximum. /// Only non-terminal jobs contribute to the count; a JOIN against JobStatusLookups /// is required because job status is a lookup-table entity, not an enum (see AI Accounting /// Features in MEMORY.md). IgnoreQueryFilters() is needed here because the global /// multi-tenancy filter would normally restrict to the current HTTP context's company, but /// this service is also called from background middleware where no HTTP context exists. /// Falls back to 50 active jobs when no plan config is found. /// public async Task<(int Used, int Max)> GetJobCountAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var terminalStatusCodes = new[] { "Completed", "Delivered", "Cancelled" }; var used = await _context.Jobs .IgnoreQueryFilters() .Where(j => j.CompanyId == companyId && !j.IsDeleted) .Join(_context.JobStatusLookups.IgnoreQueryFilters(), j => j.JobStatusId, s => s.Id, (j, s) => new { j, s }) .Where(x => !terminalStatusCodes.Contains(x.s.StatusCode)) .CountAsync(); var max = company?.MaxActiveJobsOverride ?? config?.MaxActiveJobs ?? 50; return (used, max); } /// /// Returns the total customer count (including soft-deleted) and the plan maximum. /// Soft-deleted records are counted to prevent limit circumvention. /// Falls back to 100 customers when no plan config is present. /// public async Task<(int Used, int Max)> GetCustomerCountAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var used = await _context.Customers .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId && !c.IsDeleted) .CountAsync(); var max = company?.MaxCustomersOverride ?? config?.MaxCustomers ?? 100; return (used, max); } /// /// Returns the number of quotes created this calendar month and the monthly plan limit. /// The window is anchored to UTC midnight on the first of the current month. /// A limit of -1 () means no cap. /// public async Task<(int Used, int Max)> GetQuoteCountAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var used = await _context.Quotes .IgnoreQueryFilters() .Where(q => q.CompanyId == companyId && q.CreatedAt >= startOfMonth) .CountAsync(); var max = company?.MaxQuotesOverride ?? config?.MaxQuotes ?? -1; return (used, max); } /// /// Determines the overall subscription status of a company. /// The status progression is: Active → GracePeriod → Expired. /// /// Comped companies are permanently /// regardless of SubscriptionEndDate. /// Companies with no SubscriptionEndDate are treated as Active /// (e.g., manual invoicing arrangements that don't set an end date). /// Companies within the grace period /// ( days after expiry) /// can still log in but see an expiry banner. /// Companies past the grace period are /// and are blocked by SubscriptionMiddleware. /// /// public async Task GetStatusAsync(int companyId) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); if (company == null || !company.IsActive) return SubscriptionStatus.Inactive; // Comped companies are always Active regardless of end date if (company.IsComped) return SubscriptionStatus.Active; if (company.SubscriptionEndDate == null) return SubscriptionStatus.Active; var daysUntil = DaysUntilExpiry(company); if (daysUntil == null || daysUntil > 0) return SubscriptionStatus.Active; var graceDays = await GetEffectiveGracePeriodDaysAsync(company); if (daysUntil >= -graceDays) return SubscriptionStatus.GracePeriod; return SubscriptionStatus.Expired; } /// /// Calculates the number of days remaining until SubscriptionEndDate (positive) /// or the number of days since expiry (negative). Returns null when the company /// has no end date, which the caller interprets as "does not expire." /// Date-only comparison (using .Date) ensures the result does not fluctuate /// based on the time of day the check is performed. /// public int? DaysUntilExpiry(Company company) { if (company.SubscriptionEndDate == null) return null; var today = DateTime.UtcNow.Date; var expiry = company.SubscriptionEndDate.Value.Date; return (int)(expiry - today).TotalDays; } /// /// Returns the effective grace period in days for a company. Trial companies (no Stripe subscription) /// get 0 days unless the GracePeriodAppliesToTrials platform setting is explicitly enabled. /// Paid companies always use the configured GracePeriodDays value. /// private async Task GetEffectiveGracePeriodDaysAsync(Company company) { var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId); if (isTrial) { var appliesToTrials = await _platformSettings.GetBoolAsync( PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false); if (!appliesToTrials) return 0; } return await _platformSettings.GetIntAsync( PlatformSettingKeys.GracePeriodDays, AppConstants.SubscriptionConstants.GracePeriodDays); } /// /// Returns the total catalog item count (including soft-deleted) and the plan maximum. /// Returns a max of -1 when the plan config is absent (unlimited by default). /// public async Task<(int Used, int Max)> GetCatalogItemCountAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var used = await _context.CatalogItems .IgnoreQueryFilters() .Where(c => c.CompanyId == companyId && !c.IsDeleted) .CountAsync(); return (used, company?.MaxCatalogItemsOverride ?? config?.MaxCatalogItems ?? -1); } /// /// Returns true if the company can attach another photo to the specified job. /// Photo limits are enforced per-job (not per-company) to prevent any single job from /// accumulating an unreasonable number of attachments while still allowing the overall /// company photo volume to grow with the number of jobs. /// public async Task CanAddJobPhotoAsync(int companyId, int jobId) { var (used, max) = await GetJobPhotoCountAsync(companyId, jobId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns the current photo count and the per-job limit for the specified job. /// AI analysis photos (IsAiAnalysisPhoto == true) are excluded from the count /// because they are transient artifacts of the AI quoting process, not permanent /// customer-uploaded reference photos. /// public async Task<(int Used, int Max)> GetJobPhotoCountAsync(int companyId, int jobId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var max = company?.MaxJobPhotosOverride ?? config?.MaxJobPhotos ?? -1; var used = await _context.JobPhotos .IgnoreQueryFilters() .Where(p => p.JobId == jobId && p.CompanyId == companyId && !p.IsDeleted && !p.IsAiAnalysisPhoto) .CountAsync(); return (used, max); } /// /// Returns true if the company can attach another photo to the specified quote. /// public async Task CanAddQuotePhotoAsync(int companyId, int quoteId) { var (used, max) = await GetQuotePhotoCountAsync(companyId, quoteId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns the current photo count and the per-quote limit for the specified quote. /// AI analysis photos are excluded for the same reason as . /// public async Task<(int Used, int Max)> GetQuotePhotoCountAsync(int companyId, int quoteId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var max = company?.MaxQuotePhotosOverride ?? config?.MaxQuotePhotos ?? -1; var used = await _context.QuotePhotos .IgnoreQueryFilters() .Where(p => p.QuoteId == quoteId && p.CompanyId == companyId && !p.IsDeleted && !p.IsAiAnalysisPhoto) .CountAsync(); return (used, max); } /// /// Returns true when the AI Inventory Assist feature is available for the company. /// Two gates must both pass: (1) the plan must have AllowAiInventoryAssist set to /// true in , and (2) the company-level /// AiInventoryAssistEnabled flag must be true (a SuperAdmin toggle). /// Comped companies bypass both gates. /// public async Task IsAiInventoryAssistEnabledAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company == null) return false; if (company.IsComped) return true; // Plan-level gate: feature must be enabled on the plan if (config != null && !config.AllowAiInventoryAssist) return false; return company.AiInventoryAssistEnabled; } /// /// Returns true if the company can run an AI Catalog Price Check. /// The company-level AiCatalogPriceCheckEnabled flag is checked first as an /// explicit SuperAdmin override — enabling it grants access regardless of plan tier, /// useful for granting individual companies on lower plans. If the override is not set, /// access falls back to the plan-level AllowAiCatalogPriceCheck flag. /// Comped companies bypass all gates. /// public async Task CanUseAiCatalogPriceCheckAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company == null) return false; if (company.IsComped) return true; // Company toggle is an explicit override — grants access regardless of plan if (company.AiCatalogPriceCheckEnabled) return true; // Fall back to plan-level gate return config != null && config.AllowAiCatalogPriceCheck; } public async Task CanUseAiPhotoQuoteAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company == null) return false; if (company.IsComped) return true; // Plan-level gate: feature must be enabled on the plan if (config != null && !config.AllowAiPhotoQuotes) return false; // Company-level override: SuperAdmin can disable per company if (!company.AiPhotoQuotesEnabled) return false; var (used, max) = await GetAiPhotoQuoteUsageAsync(companyId); return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max; } /// /// Returns the number of AI photo quote analyses used this calendar month and the /// monthly cap. Usage is measured against rows because /// each prediction corresponds to one Claude API call with an uploaded photo. /// The per-company override (MaxAiPhotoQuotesPerMonthOverride) takes precedence /// over the plan config value; -1 means unlimited. /// Returns (0, 0) when the company does not exist, which causes /// to return false safely. /// public async Task<(int Used, int Max)> GetAiPhotoQuoteUsageAsync(int companyId) { var (company, config) = await GetCompanyAndConfigAsync(companyId); if (company == null) return (0, 0); if (company.IsComped) return (0, AppConstants.SubscriptionConstants.UnlimitedValue); var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var used = await _context.AiItemPredictions .IgnoreQueryFilters() .Where(p => p.CompanyId == companyId && p.CreatedAt >= startOfMonth && !p.IsDeleted) .CountAsync(); var max = company.MaxAiPhotoQuotesPerMonthOverride ?? config?.MaxAiPhotoQuotesPerMonth ?? -1; return (used, max); } /// /// Shared helper that loads the record and its associated /// in two queries. Both queries use /// ignoreQueryFilters: true so that inactive or soft-deleted companies can still /// have their subscription status evaluated (e.g., by middleware during login). /// Returns (null, null) when the company does not exist; callers are expected /// to handle this as a worst-case "no access" state. /// private async Task<(Company? company, SubscriptionPlanConfig? config)> GetCompanyAndConfigAsync(int companyId) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); if (company == null) return (null, null); var config = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync( c => c.Plan == company.SubscriptionPlan && c.IsActive, ignoreQueryFilters: true); return (company, config); } }