Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Services/SubscriptionService.cs
T
spouliot 20ae11be03 Commit remaining unstaged changes from this session
- Platform settings service: IPlatformSettingsService, PlatformSettingKeys,
  PlatformSettingsService, SubscriptionService, AppConstants,
  SubscriptionExpiryBackgroundService, SubscriptionMiddleware
- JobTimeEntry entity, DTOs, AutoMapper profile (ShopWorker → UserId migration)
- InventoryDtos: SourceTransactionId on PowderUsageLogDto
- InventoryTransactionRepository: include Job.Customer in ledger query
- InventoryAiLookupService: @graph unwrap + HTML price fallback
- ApplicationDbContextModelSnapshot: reflect migration changes
- launchSettings.json, publish profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:20:30 -04:00

423 lines
20 KiB
C#

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;
/// <summary>
/// Manages subscription-based feature gating and resource-limit enforcement for tenant companies.
/// All limit checks follow a consistent priority order:
/// <list type="number">
/// <item><description>Comped companies (<c>Company.IsComped == true</c>) bypass every limit
/// and are always treated as having unlimited allowances — useful for demo accounts,
/// partners, and internal testing.</description></item>
/// <item><description>Per-company overrides (<c>Company.Max*Override</c>) take precedence
/// over plan defaults, enabling SuperAdmins to grant exceptions without changing the plan.</description></item>
/// <item><description><see cref="SubscriptionPlanConfig"/> provides the plan-level defaults
/// looked up by <c>Company.SubscriptionPlan</c>.</description></item>
/// <item><description>Hard-coded fallbacks in each method guard against missing plan config rows.</description></item>
/// </list>
/// All count queries use <c>IgnoreQueryFilters()</c> to include soft-deleted records where
/// relevant, preventing a company from circumventing limits by deleting and re-adding records.
/// </summary>
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;
}
/// <summary>
/// Returns <c>true</c> if the company can add another active user account.
/// Unlimited is indicated by <see cref="AppConstants.SubscriptionConstants.UnlimitedValue"/>
/// (-1) which short-circuits the count check.
/// </summary>
public async Task<bool> CanAddUserAsync(int companyId)
{
var (used, max) = await GetUserCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> 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.
/// </summary>
public async Task<bool> CanAddJobAsync(int companyId)
{
var (used, max) = await GetJobCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can add another customer record.
/// The count includes soft-deleted customers (<c>IgnoreQueryFilters</c>) to prevent
/// the limit from being gamed by deleting and re-creating customers.
/// </summary>
public async Task<bool> CanAddCustomerAsync(int companyId)
{
var (used, max) = await GetCustomerCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> 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.
/// </summary>
public async Task<bool> CanAddQuoteAsync(int companyId)
{
var (used, max) = await GetQuoteCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can add another catalog service item.
/// </summary>
public async Task<bool> CanAddCatalogItemAsync(int companyId)
{
var (used, max) = await GetCatalogItemCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// 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 (<c>MaxUsersOverride</c>) 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.
/// </summary>
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);
}
/// <summary>
/// Returns the active job count and the plan maximum.
/// Only non-terminal jobs contribute to the count; a JOIN against <c>JobStatusLookups</c>
/// is required because job status is a lookup-table entity, not an enum (see AI Accounting
/// Features in MEMORY.md). <c>IgnoreQueryFilters()</c> 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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 (<see cref="AppConstants.SubscriptionConstants.UnlimitedValue"/>) means no cap.
/// </summary>
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);
}
/// <summary>
/// Determines the overall subscription status of a company.
/// The status progression is: Active → GracePeriod → Expired.
/// <list type="bullet">
/// <item><description>Comped companies are permanently <see cref="SubscriptionStatus.Active"/>
/// regardless of <c>SubscriptionEndDate</c>.</description></item>
/// <item><description>Companies with no <c>SubscriptionEndDate</c> are treated as Active
/// (e.g., manual invoicing arrangements that don't set an end date).</description></item>
/// <item><description>Companies within the grace period
/// (<see cref="AppConstants.SubscriptionConstants.GracePeriodDays"/> days after expiry)
/// can still log in but see an expiry banner.</description></item>
/// <item><description>Companies past the grace period are <see cref="SubscriptionStatus.Expired"/>
/// and are blocked by <c>SubscriptionMiddleware</c>.</description></item>
/// </list>
/// </summary>
public async Task<SubscriptionStatus> 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;
}
/// <summary>
/// Calculates the number of days remaining until <c>SubscriptionEndDate</c> (positive)
/// or the number of days since expiry (negative). Returns <c>null</c> when the company
/// has no end date, which the caller interprets as "does not expire."
/// Date-only comparison (using <c>.Date</c>) ensures the result does not fluctuate
/// based on the time of day the check is performed.
/// </summary>
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;
}
/// <summary>
/// Returns the effective grace period in days for a company. Trial companies (no Stripe subscription)
/// get 0 days unless the <c>GracePeriodAppliesToTrials</c> platform setting is explicitly enabled.
/// Paid companies always use the configured <c>GracePeriodDays</c> value.
/// </summary>
private async Task<int> 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);
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// Returns <c>true</c> 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.
/// </summary>
public async Task<bool> CanAddJobPhotoAsync(int companyId, int jobId)
{
var (used, max) = await GetJobPhotoCountAsync(companyId, jobId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the current photo count and the per-job limit for the specified job.
/// AI analysis photos (<c>IsAiAnalysisPhoto == true</c>) are excluded from the count
/// because they are transient artifacts of the AI quoting process, not permanent
/// customer-uploaded reference photos.
/// </summary>
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);
}
/// <summary>
/// Returns <c>true</c> if the company can attach another photo to the specified quote.
/// </summary>
public async Task<bool> CanAddQuotePhotoAsync(int companyId, int quoteId)
{
var (used, max) = await GetQuotePhotoCountAsync(companyId, quoteId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the current photo count and the per-quote limit for the specified quote.
/// AI analysis photos are excluded for the same reason as <see cref="GetJobPhotoCountAsync"/>.
/// </summary>
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);
}
/// <summary>
/// Returns <c>true</c> when the AI Inventory Assist feature is available for the company.
/// Two gates must both pass: (1) the plan must have <c>AllowAiInventoryAssist</c> set to
/// <c>true</c> in <see cref="SubscriptionPlanConfig"/>, and (2) the company-level
/// <c>AiInventoryAssistEnabled</c> flag must be <c>true</c> (a SuperAdmin toggle).
/// Comped companies bypass both gates.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Returns <c>true</c> if the company can run an AI Catalog Price Check.
/// The company-level <c>AiCatalogPriceCheckEnabled</c> 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 <c>AllowAiCatalogPriceCheck</c> flag.
/// Comped companies bypass all gates.
/// </summary>
public async Task<bool> 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<bool> 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;
}
/// <summary>
/// Returns the number of AI photo quote analyses used this calendar month and the
/// monthly cap. Usage is measured against <see cref="AiItemPrediction"/> rows because
/// each prediction corresponds to one Claude API call with an uploaded photo.
/// The per-company override (<c>MaxAiPhotoQuotesPerMonthOverride</c>) takes precedence
/// over the plan config value; -1 means unlimited.
/// Returns (0, 0) when the company does not exist, which causes <see cref="CanUseAiPhotoQuoteAsync"/>
/// to return <c>false</c> safely.
/// </summary>
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);
}
/// <summary>
/// Shared helper that loads the <see cref="Company"/> record and its associated
/// <see cref="SubscriptionPlanConfig"/> in two queries. Both queries use
/// <c>ignoreQueryFilters: true</c> so that inactive or soft-deleted companies can still
/// have their subscription status evaluated (e.g., by middleware during login).
/// Returns <c>(null, null)</c> when the company does not exist; callers are expected
/// to handle this as a worst-case "no access" state.
/// </summary>
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);
}
}