From 10fe153d6c04f4f8ecf76737b7b918e09dce32a8 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 14:06:14 +0100 Subject: [PATCH] Phase 5H Subscription Management --- .../Controllers/ManageAccountController.cs | 27 +++ PlotLine/Data/SubscriptionRepository.cs | 10 + PlotLine/Models/SubscriptionModels.cs | 11 ++ PlotLine/Services/SubscriptionServices.cs | 10 + .../049_Phase5H_SubscriptionManagementUI.sql | 99 ++++++++++ PlotLine/ViewModels/AuthViewModels.cs | 8 + PlotLine/Views/ManageAccount/Index.cshtml | 3 + .../Views/ManageAccount/Subscription.cshtml | 187 ++++++++++++++++++ 8 files changed, 355 insertions(+) create mode 100644 PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql create mode 100644 PlotLine/Views/ManageAccount/Subscription.cshtml diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs index 0d28b49..b2192d9 100644 --- a/PlotLine/Controllers/ManageAccountController.cs +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -31,6 +31,33 @@ public sealed class ManageAccountController( }); } + [HttpGet] + public async Task Subscription() + { + var user = await GetCurrentUserAsync(); + if (user is null) + { + return RedirectToAction("Login", "Account"); + } + + var subscription = await subscriptions.GetCurrentSubscriptionAsync(user.UserID); + return View(new ManageSubscriptionViewModel + { + User = user, + CurrentSubscription = subscription, + Usage = subscription is null ? null : await subscriptions.GetUsageSummaryAsync(user.UserID), + AvailableLevels = await subscriptions.GetActiveSubscriptionLevelsAsync() + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult SubscriptionPlaceholder() + { + TempData["SubscriptionMessage"] = "Online subscription changes will be available soon."; + return RedirectToAction(nameof(Subscription)); + } + [HttpGet] public IActionResult ChangePassword() { diff --git a/PlotLine/Data/SubscriptionRepository.cs b/PlotLine/Data/SubscriptionRepository.cs index 83f20ed..f785887 100644 --- a/PlotLine/Data/SubscriptionRepository.cs +++ b/PlotLine/Data/SubscriptionRepository.cs @@ -14,6 +14,7 @@ public interface ISubscriptionRepository Task GetSceneCountForBookAsync(int bookId); Task GetCharacterCountForProjectAsync(int projectId); Task GetCollaboratorCountForOwnerAsync(int userId); + Task GetUsageSummaryForOwnerAsync(int userId); } public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository @@ -89,4 +90,13 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto new { UserID = userId }, commandType: CommandType.StoredProcedure); } + + public async Task GetUsageSummaryForOwnerAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SubscriptionUsage_GetSummaryForOwner", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } } diff --git a/PlotLine/Models/SubscriptionModels.cs b/PlotLine/Models/SubscriptionModels.cs index 4998da4..eb1933c 100644 --- a/PlotLine/Models/SubscriptionModels.cs +++ b/PlotLine/Models/SubscriptionModels.cs @@ -69,3 +69,14 @@ public sealed class SubscriptionLimitResult Message = message }; } + +public sealed class SubscriptionUsageSummary +{ + public int BooksUsed { get; set; } + public int CollaboratorsUsed { get; set; } + public int HighestChaptersInBook { get; set; } + public int HighestScenesInBook { get; set; } + public int HighestCharactersInProject { get; set; } + public long StorageUsedBytes { get; set; } + public long StorageMaximumBytes { get; set; } +} diff --git a/PlotLine/Services/SubscriptionServices.cs b/PlotLine/Services/SubscriptionServices.cs index 4e191de..da9a01f 100644 --- a/PlotLine/Services/SubscriptionServices.cs +++ b/PlotLine/Services/SubscriptionServices.cs @@ -14,6 +14,7 @@ public interface ISubscriptionService Task CanCreateCharacterAsync(int projectId, int userId); Task CanInviteCollaboratorAsync(int userId); Task GetStorageUsageAsync(int userId); + Task GetUsageSummaryAsync(int userId); Task CanUploadFileAsync(int userId, long fileSizeBytes); } @@ -75,6 +76,15 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions, I }; } + 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); diff --git a/PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql b/PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql new file mode 100644 index 0000000..ceff927 --- /dev/null +++ b/PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql @@ -0,0 +1,99 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_BookCountForOwner + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(DISTINCT b.BookID) + FROM dbo.Books b + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1 + AND b.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_CollaboratorCountForOwner + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(DISTINCT collaborator.UserID) + FROM dbo.ProjectUserAccess ownerAccess + INNER JOIN dbo.ProjectUserAccess collaborator ON collaborator.ProjectID = ownerAccess.ProjectID + WHERE ownerAccess.UserID = @UserID + AND ownerAccess.AccessRole = N'Owner' + AND ownerAccess.IsActive = 1 + AND collaborator.AccessRole = N'Collaborator' + AND collaborator.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SubscriptionUsage_GetSummaryForOwner + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + WITH OwnedProjects AS + ( + SELECT DISTINCT p.ProjectID + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1 + ), + OwnedBooks AS + ( + SELECT b.BookID, b.ProjectID + FROM dbo.Books b + INNER JOIN OwnedProjects p ON p.ProjectID = b.ProjectID + WHERE b.IsArchived = 0 + ), + BookUsage AS + ( + SELECT b.BookID, + COUNT(DISTINCT c.ChapterID) AS ChapterCount, + COUNT(DISTINCT s.SceneID) AS SceneCount + FROM OwnedBooks b + LEFT JOIN dbo.Chapters c ON c.BookID = b.BookID + AND c.IsArchived = 0 + LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID + AND s.IsArchived = 0 + GROUP BY b.BookID + ), + ProjectCharacterUsage AS + ( + SELECT p.ProjectID, + COUNT(DISTINCT c.CharacterID) AS CharacterCount + FROM OwnedProjects p + LEFT JOIN dbo.Characters c ON c.ProjectID = p.ProjectID + AND c.IsArchived = 0 + GROUP BY p.ProjectID + ) + SELECT + (SELECT COUNT(1) FROM OwnedBooks) AS BooksUsed, + ( + SELECT COUNT(DISTINCT collaborator.UserID) + FROM dbo.ProjectUserAccess ownerAccess + INNER JOIN dbo.ProjectUserAccess collaborator ON collaborator.ProjectID = ownerAccess.ProjectID + WHERE ownerAccess.UserID = @UserID + AND ownerAccess.AccessRole = N'Owner' + AND ownerAccess.IsActive = 1 + AND collaborator.AccessRole = N'Collaborator' + AND collaborator.IsActive = 1 + ) AS CollaboratorsUsed, + COALESCE((SELECT MAX(ChapterCount) FROM BookUsage), 0) AS HighestChaptersInBook, + COALESCE((SELECT MAX(SceneCount) FROM BookUsage), 0) AS HighestScenesInBook, + COALESCE((SELECT MAX(CharacterCount) FROM ProjectCharacterUsage), 0) AS HighestCharactersInProject; +END; +GO diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 7d07625..74a05e9 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -9,6 +9,14 @@ public sealed class ManageAccountViewModel public UserSubscriptionDetail? Subscription { get; set; } } +public sealed class ManageSubscriptionViewModel +{ + public AppUser User { get; set; } = new(); + public UserSubscriptionDetail? CurrentSubscription { get; set; } + public SubscriptionUsageSummary? Usage { get; set; } + public IReadOnlyList AvailableLevels { get; set; } = []; +} + public sealed class RegisterViewModel { [Required(ErrorMessage = "Enter your email address.")] diff --git a/PlotLine/Views/ManageAccount/Index.cshtml b/PlotLine/Views/ManageAccount/Index.cshtml index 1c86e04..a3befc6 100644 --- a/PlotLine/Views/ManageAccount/Index.cshtml +++ b/PlotLine/Views/ManageAccount/Index.cshtml @@ -91,6 +91,9 @@ } + @functions { diff --git a/PlotLine/Views/ManageAccount/Subscription.cshtml b/PlotLine/Views/ManageAccount/Subscription.cshtml new file mode 100644 index 0000000..d5a0835 --- /dev/null +++ b/PlotLine/Views/ManageAccount/Subscription.cshtml @@ -0,0 +1,187 @@ +@model ManageSubscriptionViewModel +@{ + ViewData["Title"] = "Subscription"; + var subscription = Model.CurrentSubscription; + var usage = Model.Usage; +} + + + +
+
+

Account

+

Subscription

+

@Model.User.Email

+
+ +
+ +@if (TempData["SubscriptionMessage"] is string subscriptionMessage) +{ +
@subscriptionMessage
+} + +
+

Current subscription

+ @if (subscription is null) + { +

No active subscription is assigned.

+ } + else + { +
+
+

Plan

+

@subscription.DisplayName

+
+
+

Description

+

@(string.IsNullOrWhiteSpace(subscription.Description) ? "No description available." : subscription.Description)

+
+
+

Status

+

@(subscription.IsActive ? "Active" : "Inactive")

+
+
+

Trial

+

@(subscription.IsTrial ? "Trial" : "No")

+
+
+

Trial expiry

+

@FormatDate(subscription.TrialExpiryDateUTC)

+
+
+

Started

+

@FormatDate(subscription.StartDateUTC)

+
+
+

Ends

+

@FormatDate(subscription.EndDateUTC)

+
+
+ +
+ + + +
+ } +
+ +
+

Usage

+ @if (subscription is null || usage is null) + { +

Usage is available after a subscription is assigned.

+ } + else + { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LimitUsage
Books@FormatUsage(usage.BooksUsed, subscription.MaxBooks, "books")
Storage@FormatStorageUsage(usage.StorageUsedBytes, usage.StorageMaximumBytes)
Collaborators@FormatUsage(usage.CollaboratorsUsed, subscription.MaxCollaborators, "collaborators")
Highest chapters in a book@FormatUsage(usage.HighestChaptersInBook, subscription.MaxChaptersPerBook, "chapters")
Highest scenes in a book@FormatUsage(usage.HighestScenesInBook, subscription.MaxScenesPerBook, "scenes")
Highest characters in a project@FormatUsage(usage.HighestCharactersInProject, subscription.MaxCharactersPerProject, "characters")
+
+ } +
+ +
+

Available plans

+ @if (!Model.AvailableLevels.Any()) + { +

No active subscription levels are available.

+ } + else + { +
+ @foreach (var level in Model.AvailableLevels) + { + var isCurrent = subscription is not null && level.SubscriptionLevelID == subscription.SubscriptionLevelID; +
+

@level.DisplayName

+

@(isCurrent ? "Current plan" : "Available plan")

+

@(string.IsNullOrWhiteSpace(level.Description) ? "No description available." : level.Description)

+
+
Books
+
@FormatLimit(level.MaxBooks)
+
Chapters / book
+
@FormatLimit(level.MaxChaptersPerBook)
+
Scenes / book
+
@FormatLimit(level.MaxScenesPerBook)
+
Characters / project
+
@FormatLimit(level.MaxCharactersPerProject)
+
Storage
+
@FormatStorageAllowance(level.MaxStorageMB)
+
Collaborators
+
@FormatLimit(level.MaxCollaborators)
+
+
+ +
+
+ } +
+ } +
+ +@functions { + private const int PracticalUnlimited = 999999; + + private static string FormatDate(DateTime? value) => + value.HasValue ? value.Value.ToLocalTime().ToString("yyyy-MM-dd") : "Not set"; + + private static string FormatLimit(int value) => + value >= PracticalUnlimited ? "Unlimited" : value.ToString("N0"); + + private static string FormatUsage(int used, int maximum, string label) => + maximum >= PracticalUnlimited + ? $"{used:N0} / Unlimited {label}" + : $"{used:N0} / {maximum:N0} {label}"; + + private static string FormatStorageAllowance(int megabytes) => + megabytes >= PracticalUnlimited + ? "Unlimited" + : StorageUsage.FormatBytes(megabytes * 1024L * 1024L); + + private static string FormatStorageUsage(long usedBytes, long maximumBytes) => + maximumBytes >= PracticalUnlimited * 1024L * 1024L + ? $"{StorageUsage.FormatBytes(usedBytes)} / Unlimited storage" + : $"{StorageUsage.FormatBytes(usedBytes)} / {StorageUsage.FormatBytes(maximumBytes)} storage"; +}