Phase 5H Subscription Management
This commit is contained in:
parent
9d0f588972
commit
10fe153d6c
@ -31,6 +31,33 @@ public sealed class ManageAccountController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> 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]
|
[HttpGet]
|
||||||
public IActionResult ChangePassword()
|
public IActionResult ChangePassword()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public interface ISubscriptionRepository
|
|||||||
Task<int> GetSceneCountForBookAsync(int bookId);
|
Task<int> GetSceneCountForBookAsync(int bookId);
|
||||||
Task<int> GetCharacterCountForProjectAsync(int projectId);
|
Task<int> GetCharacterCountForProjectAsync(int projectId);
|
||||||
Task<int> GetCollaboratorCountForOwnerAsync(int userId);
|
Task<int> GetCollaboratorCountForOwnerAsync(int userId);
|
||||||
|
Task<SubscriptionUsageSummary> GetUsageSummaryForOwnerAsync(int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
|
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
|
||||||
@ -89,4 +90,13 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto
|
|||||||
new { UserID = userId },
|
new { UserID = userId },
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SubscriptionUsageSummary> GetUsageSummaryForOwnerAsync(int userId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<SubscriptionUsageSummary>(
|
||||||
|
"dbo.SubscriptionUsage_GetSummaryForOwner",
|
||||||
|
new { UserID = userId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,3 +69,14 @@ public sealed class SubscriptionLimitResult
|
|||||||
Message = message
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public interface ISubscriptionService
|
|||||||
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
|
Task<SubscriptionLimitResult> CanCreateCharacterAsync(int projectId, int userId);
|
||||||
Task<SubscriptionLimitResult> CanInviteCollaboratorAsync(int userId);
|
Task<SubscriptionLimitResult> CanInviteCollaboratorAsync(int userId);
|
||||||
Task<StorageUsage> GetStorageUsageAsync(int userId);
|
Task<StorageUsage> GetStorageUsageAsync(int userId);
|
||||||
|
Task<SubscriptionUsageSummary> GetUsageSummaryAsync(int userId);
|
||||||
Task<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes);
|
Task<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +76,15 @@ public sealed class SubscriptionService(ISubscriptionRepository subscriptions, I
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SubscriptionUsageSummary> 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<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes)
|
public async Task<StorageLimitResult> CanUploadFileAsync(int userId, long fileSizeBytes)
|
||||||
{
|
{
|
||||||
var usage = await GetStorageUsageAsync(userId);
|
var usage = await GetStorageUsageAsync(userId);
|
||||||
|
|||||||
99
PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql
Normal file
99
PlotLine/Sql/049_Phase5H_SubscriptionManagementUI.sql
Normal file
@ -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
|
||||||
@ -9,6 +9,14 @@ public sealed class ManageAccountViewModel
|
|||||||
public UserSubscriptionDetail? Subscription { get; set; }
|
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<SubscriptionLevel> AvailableLevels { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class RegisterViewModel
|
public sealed class RegisterViewModel
|
||||||
{
|
{
|
||||||
[Required(ErrorMessage = "Enter your email address.")]
|
[Required(ErrorMessage = "Enter your email address.")]
|
||||||
|
|||||||
@ -91,6 +91,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="button-row mt-3">
|
||||||
|
<a class="btn btn-outline-primary" asp-action="Subscription">View subscription details</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
|
|||||||
187
PlotLine/Views/ManageAccount/Subscription.cshtml
Normal file
187
PlotLine/Views/ManageAccount/Subscription.cshtml
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
@model ManageSubscriptionViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Subscription";
|
||||||
|
var subscription = Model.CurrentSubscription;
|
||||||
|
var usage = Model.Usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||||
|
<a asp-controller="ManageAccount" asp-action="Index">Manage Account</a>
|
||||||
|
<span>Subscription</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page-heading compact">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account</p>
|
||||||
|
<h1>Subscription</h1>
|
||||||
|
<p class="lead-text">@Model.User.Email</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="Index">Back to account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (TempData["SubscriptionMessage"] is string subscriptionMessage)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">@subscriptionMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="edit-panel">
|
||||||
|
<h2>Current subscription</h2>
|
||||||
|
@if (subscription is null)
|
||||||
|
{
|
||||||
|
<p class="muted">No active subscription is assigned.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<p class="eyebrow">Plan</p>
|
||||||
|
<p>@subscription.DisplayName</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<p class="eyebrow">Description</p>
|
||||||
|
<p>@(string.IsNullOrWhiteSpace(subscription.Description) ? "No description available." : subscription.Description)</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Status</p>
|
||||||
|
<p>@(subscription.IsActive ? "Active" : "Inactive")</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Trial</p>
|
||||||
|
<p>@(subscription.IsTrial ? "Trial" : "No")</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Trial expiry</p>
|
||||||
|
<p>@FormatDate(subscription.TrialExpiryDateUTC)</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Started</p>
|
||||||
|
<p>@FormatDate(subscription.StartDateUTC)</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<p class="eyebrow">Ends</p>
|
||||||
|
<p>@FormatDate(subscription.EndDateUTC)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form asp-action="SubscriptionPlaceholder" method="post" class="button-row mt-3">
|
||||||
|
<button class="btn btn-outline-primary" type="submit">Upgrade</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Downgrade</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">Manage Billing</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="edit-panel">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
@if (subscription is null || usage is null)
|
||||||
|
{
|
||||||
|
<p class="muted">Usage is available after a subscription is assigned.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Limit</th>
|
||||||
|
<th>Usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Books</td>
|
||||||
|
<td>@FormatUsage(usage.BooksUsed, subscription.MaxBooks, "books")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Storage</td>
|
||||||
|
<td>@FormatStorageUsage(usage.StorageUsedBytes, usage.StorageMaximumBytes)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Collaborators</td>
|
||||||
|
<td>@FormatUsage(usage.CollaboratorsUsed, subscription.MaxCollaborators, "collaborators")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Highest chapters in a book</td>
|
||||||
|
<td>@FormatUsage(usage.HighestChaptersInBook, subscription.MaxChaptersPerBook, "chapters")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Highest scenes in a book</td>
|
||||||
|
<td>@FormatUsage(usage.HighestScenesInBook, subscription.MaxScenesPerBook, "scenes")</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Highest characters in a project</td>
|
||||||
|
<td>@FormatUsage(usage.HighestCharactersInProject, subscription.MaxCharactersPerProject, "characters")</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="list-section">
|
||||||
|
<h2>Available plans</h2>
|
||||||
|
@if (!Model.AvailableLevels.Any())
|
||||||
|
{
|
||||||
|
<p class="muted">No active subscription levels are available.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="content-grid">
|
||||||
|
@foreach (var level in Model.AvailableLevels)
|
||||||
|
{
|
||||||
|
var isCurrent = subscription is not null && level.SubscriptionLevelID == subscription.SubscriptionLevelID;
|
||||||
|
<article class="plain-card">
|
||||||
|
<h3>@level.DisplayName</h3>
|
||||||
|
<p class="eyebrow">@(isCurrent ? "Current plan" : "Available plan")</p>
|
||||||
|
<p>@(string.IsNullOrWhiteSpace(level.Description) ? "No description available." : level.Description)</p>
|
||||||
|
<dl class="row small">
|
||||||
|
<dt class="col-7">Books</dt>
|
||||||
|
<dd class="col-5">@FormatLimit(level.MaxBooks)</dd>
|
||||||
|
<dt class="col-7">Chapters / book</dt>
|
||||||
|
<dd class="col-5">@FormatLimit(level.MaxChaptersPerBook)</dd>
|
||||||
|
<dt class="col-7">Scenes / book</dt>
|
||||||
|
<dd class="col-5">@FormatLimit(level.MaxScenesPerBook)</dd>
|
||||||
|
<dt class="col-7">Characters / project</dt>
|
||||||
|
<dd class="col-5">@FormatLimit(level.MaxCharactersPerProject)</dd>
|
||||||
|
<dt class="col-7">Storage</dt>
|
||||||
|
<dd class="col-5">@FormatStorageAllowance(level.MaxStorageMB)</dd>
|
||||||
|
<dt class="col-7">Collaborators</dt>
|
||||||
|
<dd class="col-5">@FormatLimit(level.MaxCollaborators)</dd>
|
||||||
|
</dl>
|
||||||
|
<form asp-action="SubscriptionPlaceholder" method="post">
|
||||||
|
<button class="btn @(isCurrent ? "btn-outline-secondary" : "btn-outline-primary") btn-sm" type="submit">
|
||||||
|
@(isCurrent ? "Manage Billing" : "Change Plan")
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@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";
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user