20ae11be03
- 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>
423 lines
20 KiB
C#
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);
|
|
}
|
|
}
|