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);
}
}