using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface ISubscriptionService { Task GetCurrentSubscriptionAsync(int userId); Task HasPaidBillingRiskAsync(int userId); Task GetSubscriptionLevelAsync(int subscriptionLevelId); Task> GetActiveSubscriptionLevelsAsync(); Task CanCreateBookAsync(int userId); Task CanCreateChapterAsync(int bookId, int userId); Task CanCreateSceneAsync(int bookId, int userId); Task CanCreateCharacterAsync(int projectId, int userId); Task CanInviteCollaboratorAsync(int userId); Task GetStorageUsageAsync(int userId); Task GetUsageSummaryAsync(int userId); Task CanUploadFileAsync(int userId, long fileSizeBytes); } public sealed class SubscriptionService(ISubscriptionRepository subscriptions, IUserFileRepository userFiles) : ISubscriptionService { private const int PracticalUnlimited = 999999; public Task GetCurrentSubscriptionAsync(int userId) => subscriptions.GetCurrentSubscriptionAsync(userId); public async Task HasPaidBillingRiskAsync(int userId) { var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId); return HasPaidBillingRisk(subscription, DateTime.UtcNow); } public Task GetSubscriptionLevelAsync(int subscriptionLevelId) => subscriptions.GetSubscriptionLevelAsync(subscriptionLevelId); public Task> GetActiveSubscriptionLevelsAsync() => subscriptions.GetActiveSubscriptionLevelsAsync(); public async Task CanCreateBookAsync(int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var currentUsage = await subscriptions.GetOwnedBookCountAsync(userId); return Evaluate(subscription, currentUsage, subscription.MaxBooks, "books"); } public async Task CanCreateChapterAsync(int bookId, int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var currentUsage = await subscriptions.GetChapterCountForBookAsync(bookId); return Evaluate(subscription, currentUsage, subscription.MaxChaptersPerBook, "chapters per book"); } public async Task CanCreateSceneAsync(int bookId, int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var currentUsage = await subscriptions.GetSceneCountForBookAsync(bookId); return Evaluate(subscription, currentUsage, subscription.MaxScenesPerBook, "scenes per book"); } public async Task CanCreateCharacterAsync(int projectId, int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var currentUsage = await subscriptions.GetCharacterCountForProjectAsync(projectId); return Evaluate(subscription, currentUsage, subscription.MaxCharactersPerProject, "characters per project"); } public async Task CanInviteCollaboratorAsync(int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var currentUsage = await subscriptions.GetCollaboratorCountForOwnerAsync(userId); return Evaluate(subscription, currentUsage, subscription.MaxCollaborators, "collaborators"); } public async Task GetStorageUsageAsync(int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); return new StorageUsage { UsedBytes = await userFiles.GetStorageUsageForOwnerAsync(userId), MaximumBytes = ToBytes(subscription.MaxStorageMB) }; } public async Task GetUsageSummaryAsync(int userId) { var subscription = await GetRequiredSubscriptionAsync(userId); var usage = await subscriptions.GetUsageSummaryForOwnerAsync(userId); usage.StorageUsedBytes = await userFiles.GetStorageUsageForOwnerAsync(userId); usage.StorageMaximumBytes = ToBytes(subscription.MaxStorageMB); return usage; } public async Task CanUploadFileAsync(int userId, long fileSizeBytes) { var usage = await GetStorageUsageAsync(userId); if (usage.MaximumBytes >= ToBytes(PracticalUnlimited) || usage.UsedBytes + fileSizeBytes <= usage.MaximumBytes) { return StorageLimitResult.AllowedResult(usage.UsedBytes, usage.MaximumBytes); } return StorageLimitResult.Blocked(usage.UsedBytes, usage.MaximumBytes, fileSizeBytes); } private async Task GetRequiredSubscriptionAsync(int userId) { var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId); if (subscription is null) { throw new SubscriptionLimitException("No active subscription is assigned to your account."); } return subscription; } private static SubscriptionLimitResult Evaluate(UserSubscriptionDetail subscription, int currentUsage, int maximumAllowed, string limitName) { if (maximumAllowed >= PracticalUnlimited || currentUsage < maximumAllowed) { return SubscriptionLimitResult.AllowedResult(currentUsage, maximumAllowed); } return SubscriptionLimitResult.Blocked( currentUsage, maximumAllowed, $"You have reached the maximum number of {limitName} allowed by your {subscription.DisplayName} subscription ({maximumAllowed:N0}). Upgrade your subscription to add more."); } private static long ToBytes(int megabytes) => megabytes * 1024L * 1024L; internal static bool HasPaidBillingRisk(UserSubscriptionDetail? subscription, DateTime utcNow) { if (subscription is null || string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId)) { return false; } var stripeStatus = (subscription.StripeStatus ?? string.Empty).Trim().ToLowerInvariant(); if (stripeStatus is "canceled" or "cancelled" or "incomplete_expired") { return false; } if (subscription.CancelAtPeriodEnd && (!subscription.CurrentPeriodEndUTC.HasValue || subscription.CurrentPeriodEndUTC.Value > utcNow)) { return true; } if (subscription.CurrentPeriodEndUTC.HasValue && subscription.CurrentPeriodEndUTC.Value <= utcNow && stripeStatus is "ended" or "expired" or "inactive") { return false; } return stripeStatus is "active" or "trialing" or "past_due" or "unpaid" or "incomplete" or "paused" || string.IsNullOrWhiteSpace(stripeStatus); } } public sealed class SubscriptionLimitException(string message) : InvalidOperationException(message);