Phase 5I – Stripe Integration Completed

This commit is contained in:
Nick Beckley 2026-06-07 14:59:41 +01:00
parent 300bdcdca1
commit 1e03241d4e
10 changed files with 1041 additions and 12 deletions

View File

@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
@ -13,7 +14,8 @@ public sealed class ManageAccountController(
IUserRepository users,
IAuthService authService,
ITwoFactorService twoFactorService,
ISubscriptionService subscriptions) : Controller
ISubscriptionService subscriptions,
IStripeBillingService stripeBilling) : Controller
{
[HttpGet]
public async Task<IActionResult> Index()
@ -58,6 +60,84 @@ public sealed class ManageAccountController(
return RedirectToAction(nameof(Subscription));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubscriptionCheckout(StripeCheckoutRequest request)
{
var user = await GetCurrentUserAsync();
if (user is null)
{
return RedirectToAction("Login", "Account");
}
var successUrl = Url.Action(nameof(SubscriptionSuccess), "ManageAccount", null, Request.Scheme) ?? string.Empty;
var cancelUrl = Url.Action(nameof(SubscriptionCancelled), "ManageAccount", null, Request.Scheme) ?? string.Empty;
var result = await stripeBilling.StartCheckoutOrChangePlanAsync(user, request, successUrl, cancelUrl);
if (!result.Succeeded)
{
TempData["SubscriptionMessage"] = result.Message;
return RedirectToAction(nameof(Subscription));
}
if (!string.IsNullOrWhiteSpace(result.RedirectUrl))
{
return Redirect(result.RedirectUrl);
}
TempData["SubscriptionMessage"] = result.Message;
return RedirectToAction(nameof(Subscription));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CancelSubscription()
{
var user = await GetCurrentUserAsync();
if (user is null)
{
return RedirectToAction("Login", "Account");
}
var result = await stripeBilling.CancelAtPeriodEndAsync(user);
TempData["SubscriptionMessage"] = result.Message;
return RedirectToAction(nameof(Subscription));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ManageBilling()
{
var user = await GetCurrentUserAsync();
if (user is null)
{
return RedirectToAction("Login", "Account");
}
var returnUrl = Url.Action(nameof(Subscription), "ManageAccount", null, Request.Scheme) ?? string.Empty;
var result = await stripeBilling.CreateBillingPortalSessionAsync(user, returnUrl);
if (result.Succeeded && !string.IsNullOrWhiteSpace(result.RedirectUrl))
{
return Redirect(result.RedirectUrl);
}
TempData["SubscriptionMessage"] = result.Message;
return RedirectToAction(nameof(Subscription));
}
[HttpGet]
public IActionResult SubscriptionSuccess()
{
TempData["SubscriptionMessage"] = "Thank you. Your subscription is being confirmed. This usually only takes a few moments.";
return RedirectToAction(nameof(Subscription));
}
[HttpGet]
public IActionResult SubscriptionCancelled()
{
TempData["SubscriptionMessage"] = "Stripe Checkout was cancelled. No subscription changes were made.";
return RedirectToAction(nameof(Subscription));
}
[HttpGet]
public IActionResult ChangePassword()
{

View File

@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
[AllowAnonymous]
[ApiController]
[Route("api/stripe/webhook")]
public sealed class StripeWebhookController(IStripeBillingService stripeBilling) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Post()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].ToString();
var result = await stripeBilling.HandleWebhookAsync(json, signature);
return StatusCode(result.StatusCode, result.Message);
}
}

View File

@ -15,6 +15,14 @@ public interface ISubscriptionRepository
Task<int> GetCharacterCountForProjectAsync(int projectId);
Task<int> GetCollaboratorCountForOwnerAsync(int userId);
Task<SubscriptionUsageSummary> GetUsageSummaryForOwnerAsync(int userId);
Task<SubscriptionLevel?> GetSubscriptionLevelByStripePriceAsync(string stripePriceId);
Task SetStripeCustomerAsync(int userId, string stripeCustomerId);
Task<bool> TryBeginStripeWebhookEventAsync(string stripeEventId, string eventType);
Task CompleteStripeWebhookEventAsync(string stripeEventId, string processingStatus, string? errorMessage = null);
Task ApplyStripeStateAsync(StripeSubscriptionSyncState state);
Task DowngradeToDraftDeskAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId);
Task RecordPaymentFailureAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId);
Task ClearPaymentFailureAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId);
}
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
@ -99,4 +107,76 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<SubscriptionLevel?> GetSubscriptionLevelByStripePriceAsync(string stripePriceId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<SubscriptionLevel>(
"dbo.SubscriptionLevel_GetByStripePrice",
new { StripePriceId = stripePriceId },
commandType: CommandType.StoredProcedure);
}
public async Task SetStripeCustomerAsync(int userId, string stripeCustomerId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserSubscription_SetStripeCustomer",
new { UserID = userId, StripeCustomerId = stripeCustomerId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> TryBeginStripeWebhookEventAsync(string stripeEventId, string eventType)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.StripeWebhookEvent_TryBegin",
new { StripeEventId = stripeEventId, EventType = eventType },
commandType: CommandType.StoredProcedure);
}
public async Task CompleteStripeWebhookEventAsync(string stripeEventId, string processingStatus, string? errorMessage = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StripeWebhookEvent_Complete",
new { StripeEventId = stripeEventId, ProcessingStatus = processingStatus, ErrorMessage = errorMessage },
commandType: CommandType.StoredProcedure);
}
public async Task ApplyStripeStateAsync(StripeSubscriptionSyncState state)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserSubscription_ApplyStripeState",
state,
commandType: CommandType.StoredProcedure);
}
public async Task DowngradeToDraftDeskAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserSubscription_DowngradeToDraftDesk",
new { StripeCustomerId = stripeCustomerId, StripeSubscriptionId = stripeSubscriptionId, LastStripeEventId = lastStripeEventId },
commandType: CommandType.StoredProcedure);
}
public async Task RecordPaymentFailureAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserSubscription_RecordPaymentFailure",
new { StripeCustomerId = stripeCustomerId, StripeSubscriptionId = stripeSubscriptionId, LastStripeEventId = lastStripeEventId },
commandType: CommandType.StoredProcedure);
}
public async Task ClearPaymentFailureAsync(string? stripeCustomerId, string? stripeSubscriptionId, string? lastStripeEventId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.UserSubscription_ClearPaymentFailure",
new { StripeCustomerId = stripeCustomerId, StripeSubscriptionId = stripeSubscriptionId, LastStripeEventId = lastStripeEventId },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -0,0 +1,8 @@
namespace PlotLine.Models;
public sealed class StripeSettings
{
public string PublishableKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
}

View File

@ -16,6 +16,10 @@ public sealed class SubscriptionLevel
public int SortOrder { get; set; }
public DateTime CreatedDateUTC { get; set; }
public DateTime ModifiedDateUTC { get; set; }
public string? StripeMonthlyPriceId { get; set; }
public string? StripeAnnualPriceId { get; set; }
public bool RequiresStripeSubscription { get; set; }
public string? BillingInterval { get; set; }
}
public class UserSubscription
@ -30,6 +34,17 @@ public class UserSubscription
public DateTime? TrialExpiryDateUTC { get; set; }
public DateTime CreatedDateUTC { get; set; }
public DateTime ModifiedDateUTC { get; set; }
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public string? StripePriceId { get; set; }
public string? BillingInterval { get; set; }
public string? StripeStatus { get; set; }
public DateTime? CurrentPeriodStartUTC { get; set; }
public DateTime? CurrentPeriodEndUTC { get; set; }
public bool CancelAtPeriodEnd { get; set; }
public DateTime? CancelledDateUTC { get; set; }
public DateTime? LastPaymentFailureDateUTC { get; set; }
public string? LastStripeEventId { get; set; }
}
public sealed class UserSubscriptionDetail : UserSubscription
@ -80,3 +95,24 @@ public sealed class SubscriptionUsageSummary
public long StorageUsedBytes { get; set; }
public long StorageMaximumBytes { get; set; }
}
public sealed class StripeSubscriptionSyncState
{
public int? UserID { get; set; }
public int? SubscriptionLevelID { get; set; }
public string StripeCustomerId { get; set; } = string.Empty;
public string StripeSubscriptionId { get; set; } = string.Empty;
public string StripePriceId { get; set; } = string.Empty;
public string? BillingInterval { get; set; }
public string StripeStatus { get; set; } = string.Empty;
public DateTime? CurrentPeriodStartUTC { get; set; }
public DateTime? CurrentPeriodEndUTC { get; set; }
public bool CancelAtPeriodEnd { get; set; }
public string? LastStripeEventId { get; set; }
}
public sealed class StripeCheckoutRequest
{
public int SubscriptionLevelID { get; set; }
public string BillingInterval { get; set; } = "Monthly";
}

View File

@ -11,6 +11,7 @@
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Stripe.net" Version="52.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -32,6 +32,7 @@ public class Program
options.Cookie.SameSite = SameSiteMode.Lax;
});
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@ -136,6 +137,7 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
builder.Services.AddSingleton<IHelpService, HelpService>();
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService, AuthService>();

View File

@ -0,0 +1,381 @@
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using Stripe;
using Stripe.Checkout;
namespace PlotLine.Services;
public interface IStripeBillingService
{
Task<StripeBillingActionResult> StartCheckoutOrChangePlanAsync(AppUser user, StripeCheckoutRequest request, string successUrl, string cancelUrl);
Task<StripeBillingActionResult> CancelAtPeriodEndAsync(AppUser user);
Task<StripeBillingActionResult> CreateBillingPortalSessionAsync(AppUser user, string returnUrl);
Task<StripeWebhookHandleResult> HandleWebhookAsync(string json, string signatureHeader);
}
public sealed class StripeBillingActionResult
{
public bool Succeeded { get; init; }
public string? RedirectUrl { get; init; }
public string Message { get; init; } = string.Empty;
public static StripeBillingActionResult Success(string message, string? redirectUrl = null) => new() { Succeeded = true, Message = message, RedirectUrl = redirectUrl };
public static StripeBillingActionResult Failure(string message) => new() { Message = message };
}
public sealed class StripeWebhookHandleResult
{
public int StatusCode { get; init; }
public string Message { get; init; } = string.Empty;
public static StripeWebhookHandleResult Ok(string message = "OK") => new() { StatusCode = StatusCodes.Status200OK, Message = message };
public static StripeWebhookHandleResult BadRequest(string message) => new() { StatusCode = StatusCodes.Status400BadRequest, Message = message };
}
public sealed class StripeBillingService(
ISubscriptionRepository subscriptions,
ISubscriptionService subscriptionService,
IOptions<StripeSettings> stripeOptions,
ILogger<StripeBillingService> logger) : IStripeBillingService
{
private readonly StripeSettings settings = stripeOptions.Value;
public async Task<StripeBillingActionResult> StartCheckoutOrChangePlanAsync(AppUser user, StripeCheckoutRequest request, string successUrl, string cancelUrl)
{
var level = await subscriptions.GetSubscriptionLevelAsync(request.SubscriptionLevelID);
if (level is null || !level.IsActive)
{
return StripeBillingActionResult.Failure("The selected subscription plan is not available.");
}
if (!level.RequiresStripeSubscription)
{
return StripeBillingActionResult.Failure("Draft Desk is managed inside PlotWeaver Studio and does not require Stripe Checkout.");
}
var billingInterval = NormalizeBillingInterval(request.BillingInterval);
var priceId = ResolvePriceId(level, billingInterval);
if (string.IsNullOrWhiteSpace(priceId))
{
return StripeBillingActionResult.Failure("The selected billing interval is not available for this plan.");
}
if (string.IsNullOrWhiteSpace(settings.SecretKey))
{
return StripeBillingActionResult.Failure("Stripe test configuration is not available.");
}
StripeConfiguration.ApiKey = settings.SecretKey;
var currentSubscription = await subscriptionService.GetCurrentSubscriptionAsync(user.UserID);
if (!string.IsNullOrWhiteSpace(currentSubscription?.StripeSubscriptionId)
&& IsStripeSubscriptionActive(currentSubscription.StripeStatus))
{
var updateResult = await ChangeExistingSubscriptionAsync(currentSubscription, level, priceId, billingInterval);
return updateResult;
}
var customerId = await GetOrCreateCustomerAsync(user, currentSubscription);
var sessionService = new Stripe.Checkout.SessionService();
var session = await sessionService.CreateAsync(new Stripe.Checkout.SessionCreateOptions
{
Mode = "subscription",
Customer = customerId,
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = user.UserID.ToString(),
Metadata = new Dictionary<string, string>
{
["UserID"] = user.UserID.ToString(),
["SubscriptionLevelID"] = level.SubscriptionLevelID.ToString(),
["BillingInterval"] = billingInterval
},
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
["UserID"] = user.UserID.ToString(),
["SubscriptionLevelID"] = level.SubscriptionLevelID.ToString(),
["BillingInterval"] = billingInterval
}
},
LineItems =
[
new SessionLineItemOptions
{
Price = priceId,
Quantity = 1
}
]
});
return StripeBillingActionResult.Success("Redirecting to Stripe Checkout.", session.Url);
}
public async Task<StripeBillingActionResult> CancelAtPeriodEndAsync(AppUser user)
{
var currentSubscription = await subscriptionService.GetCurrentSubscriptionAsync(user.UserID);
if (string.IsNullOrWhiteSpace(currentSubscription?.StripeSubscriptionId))
{
return StripeBillingActionResult.Failure("No paid Stripe subscription is active for this account.");
}
if (string.IsNullOrWhiteSpace(settings.SecretKey))
{
return StripeBillingActionResult.Failure("Stripe test configuration is not available.");
}
StripeConfiguration.ApiKey = settings.SecretKey;
var stripeSubscription = await new Stripe.SubscriptionService().UpdateAsync(
currentSubscription.StripeSubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = true });
await subscriptions.ApplyStripeStateAsync(BuildSyncState(stripeSubscription, user.UserID, currentSubscription.SubscriptionLevelID, currentSubscription.BillingInterval, null));
return StripeBillingActionResult.Success("Your subscription will cancel at the end of the current billing period.");
}
public async Task<StripeBillingActionResult> CreateBillingPortalSessionAsync(AppUser user, string returnUrl)
{
var currentSubscription = await subscriptionService.GetCurrentSubscriptionAsync(user.UserID);
if (string.IsNullOrWhiteSpace(currentSubscription?.StripeCustomerId))
{
return StripeBillingActionResult.Failure("No Stripe customer is linked to this account yet.");
}
if (string.IsNullOrWhiteSpace(settings.SecretKey))
{
return StripeBillingActionResult.Failure("Stripe test configuration is not available.");
}
StripeConfiguration.ApiKey = settings.SecretKey;
try
{
var portalSession = await new Stripe.BillingPortal.SessionService().CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = currentSubscription.StripeCustomerId,
ReturnUrl = returnUrl
});
return StripeBillingActionResult.Success("Redirecting to Stripe Billing Portal.", portalSession.Url);
}
catch (StripeException ex)
{
logger.LogWarning(ex, "Stripe Billing Portal session could not be created for user {UserID}.", user.UserID);
return StripeBillingActionResult.Failure("Stripe Billing Portal is not available yet.");
}
}
public async Task<StripeWebhookHandleResult> HandleWebhookAsync(string json, string signatureHeader)
{
if (string.IsNullOrWhiteSpace(settings.WebhookSecret))
{
return StripeWebhookHandleResult.BadRequest("Stripe webhook configuration is not available.");
}
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, settings.WebhookSecret);
}
catch (StripeException ex)
{
logger.LogWarning(ex, "Invalid Stripe webhook signature.");
return StripeWebhookHandleResult.BadRequest("Invalid Stripe signature.");
}
var shouldProcess = await subscriptions.TryBeginStripeWebhookEventAsync(stripeEvent.Id, stripeEvent.Type);
if (!shouldProcess)
{
return StripeWebhookHandleResult.Ok("Duplicate event ignored.");
}
try
{
await ProcessWebhookEventAsync(stripeEvent);
await subscriptions.CompleteStripeWebhookEventAsync(stripeEvent.Id, "Processed");
return StripeWebhookHandleResult.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Stripe webhook {EventId} failed.", stripeEvent.Id);
await subscriptions.CompleteStripeWebhookEventAsync(stripeEvent.Id, "Failed", ex.Message);
return StripeWebhookHandleResult.BadRequest("Webhook processing failed.");
}
}
private async Task<StripeBillingActionResult> ChangeExistingSubscriptionAsync(UserSubscriptionDetail currentSubscription, SubscriptionLevel level, string priceId, string billingInterval)
{
var stripeSubscriptionService = new Stripe.SubscriptionService();
var stripeSubscription = await stripeSubscriptionService.GetAsync(currentSubscription.StripeSubscriptionId);
var itemId = stripeSubscription.Items?.Data?.FirstOrDefault()?.Id;
if (string.IsNullOrWhiteSpace(itemId))
{
return StripeBillingActionResult.Failure("The current Stripe subscription could not be updated.");
}
var updated = await stripeSubscriptionService.UpdateAsync(
currentSubscription.StripeSubscriptionId,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = itemId,
Price = priceId
}
],
Metadata = new Dictionary<string, string>
{
["UserID"] = currentSubscription.UserID.ToString(),
["SubscriptionLevelID"] = level.SubscriptionLevelID.ToString(),
["BillingInterval"] = billingInterval
}
});
await subscriptions.ApplyStripeStateAsync(BuildSyncState(updated, currentSubscription.UserID, level.SubscriptionLevelID, billingInterval, null));
return StripeBillingActionResult.Success("Your subscription change has been sent to Stripe.");
}
private async Task<string> GetOrCreateCustomerAsync(AppUser user, UserSubscriptionDetail? currentSubscription)
{
if (!string.IsNullOrWhiteSpace(currentSubscription?.StripeCustomerId))
{
return currentSubscription.StripeCustomerId;
}
var customer = await new CustomerService().CreateAsync(new CustomerCreateOptions
{
Email = user.Email,
Name = user.DisplayName,
Metadata = new Dictionary<string, string> { ["UserID"] = user.UserID.ToString() }
});
await subscriptions.SetStripeCustomerAsync(user.UserID, customer.Id);
return customer.Id;
}
private async Task ProcessWebhookEventAsync(Event stripeEvent)
{
switch (stripeEvent.Type)
{
case "checkout.session.completed":
await HandleCheckoutSessionCompletedAsync(stripeEvent);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
await HandleSubscriptionUpdatedAsync(stripeEvent);
break;
case "customer.subscription.deleted":
await HandleSubscriptionDeletedAsync(stripeEvent);
break;
case "invoice.payment_failed":
await HandleInvoicePaymentFailedAsync(stripeEvent);
break;
case "invoice.payment_succeeded":
await HandleInvoicePaymentSucceededAsync(stripeEvent);
break;
default:
logger.LogInformation("Stripe webhook event {EventType} ignored.", stripeEvent.Type);
break;
}
}
private async Task HandleCheckoutSessionCompletedAsync(Event stripeEvent)
{
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
if (session is null || string.IsNullOrWhiteSpace(session.SubscriptionId))
{
return;
}
int.TryParse(session.Metadata.GetValueOrDefault("UserID"), out var userId);
int.TryParse(session.Metadata.GetValueOrDefault("SubscriptionLevelID"), out var subscriptionLevelId);
var billingInterval = session.Metadata.GetValueOrDefault("BillingInterval");
var stripeSubscription = await new Stripe.SubscriptionService().GetAsync(session.SubscriptionId);
await subscriptions.ApplyStripeStateAsync(BuildSyncState(stripeSubscription, userId == 0 ? null : userId, subscriptionLevelId == 0 ? null : subscriptionLevelId, billingInterval, stripeEvent.Id));
}
private async Task HandleSubscriptionUpdatedAsync(Event stripeEvent)
{
var stripeSubscription = stripeEvent.Data.Object as Subscription;
if (stripeSubscription is null)
{
return;
}
int.TryParse(stripeSubscription.Metadata.GetValueOrDefault("UserID"), out var userId);
int.TryParse(stripeSubscription.Metadata.GetValueOrDefault("SubscriptionLevelID"), out var subscriptionLevelId);
var billingInterval = stripeSubscription.Metadata.GetValueOrDefault("BillingInterval");
await subscriptions.ApplyStripeStateAsync(BuildSyncState(stripeSubscription, userId == 0 ? null : userId, subscriptionLevelId == 0 ? null : subscriptionLevelId, billingInterval, stripeEvent.Id));
}
private async Task HandleSubscriptionDeletedAsync(Event stripeEvent)
{
var stripeSubscription = stripeEvent.Data.Object as Subscription;
if (stripeSubscription is null)
{
return;
}
await subscriptions.DowngradeToDraftDeskAsync(stripeSubscription.CustomerId, stripeSubscription.Id, stripeEvent.Id);
}
private async Task HandleInvoicePaymentFailedAsync(Event stripeEvent)
{
var invoice = stripeEvent.Data.Object as Invoice;
if (invoice is null)
{
return;
}
await subscriptions.RecordPaymentFailureAsync(invoice.CustomerId, invoice.Parent?.SubscriptionDetails?.SubscriptionId, stripeEvent.Id);
}
private async Task HandleInvoicePaymentSucceededAsync(Event stripeEvent)
{
var invoice = stripeEvent.Data.Object as Invoice;
if (invoice is null)
{
return;
}
await subscriptions.ClearPaymentFailureAsync(invoice.CustomerId, invoice.Parent?.SubscriptionDetails?.SubscriptionId, stripeEvent.Id);
}
private static StripeSubscriptionSyncState BuildSyncState(Subscription stripeSubscription, int? userId, int? subscriptionLevelId, string? billingInterval, string? eventId)
{
var item = stripeSubscription.Items?.Data?.FirstOrDefault();
var price = item?.Price;
return new StripeSubscriptionSyncState
{
UserID = userId,
SubscriptionLevelID = subscriptionLevelId,
StripeCustomerId = stripeSubscription.CustomerId ?? string.Empty,
StripeSubscriptionId = stripeSubscription.Id,
StripePriceId = price?.Id ?? string.Empty,
BillingInterval = billingInterval ?? StripeIntervalToBillingInterval(price?.Recurring?.Interval),
StripeStatus = stripeSubscription.Status ?? string.Empty,
CurrentPeriodStartUTC = item?.CurrentPeriodStart,
CurrentPeriodEndUTC = item?.CurrentPeriodEnd,
CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd,
LastStripeEventId = eventId
};
}
private static string NormalizeBillingInterval(string? value) =>
string.Equals(value, "Annual", StringComparison.OrdinalIgnoreCase) ? "Annual" : "Monthly";
private static string? ResolvePriceId(SubscriptionLevel level, string billingInterval) =>
string.Equals(billingInterval, "Annual", StringComparison.OrdinalIgnoreCase)
? level.StripeAnnualPriceId
: level.StripeMonthlyPriceId;
private static string? StripeIntervalToBillingInterval(string? interval) =>
string.Equals(interval, "year", StringComparison.OrdinalIgnoreCase) ? "Annual" :
string.Equals(interval, "month", StringComparison.OrdinalIgnoreCase) ? "Monthly" :
null;
private static bool IsStripeSubscriptionActive(string? status) =>
string.Equals(status, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, "trialing", StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,379 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.SubscriptionLevel') AND name = N'StripeMonthlyPriceId')
ALTER TABLE dbo.SubscriptionLevel ADD StripeMonthlyPriceId nvarchar(100) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.SubscriptionLevel') AND name = N'StripeAnnualPriceId')
ALTER TABLE dbo.SubscriptionLevel ADD StripeAnnualPriceId nvarchar(100) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.SubscriptionLevel') AND name = N'RequiresStripeSubscription')
ALTER TABLE dbo.SubscriptionLevel ADD RequiresStripeSubscription bit NOT NULL CONSTRAINT DF_SubscriptionLevel_RequiresStripeSubscription DEFAULT 0;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'StripeCustomerId')
ALTER TABLE dbo.UserSubscription ADD StripeCustomerId nvarchar(100) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'StripeSubscriptionId')
ALTER TABLE dbo.UserSubscription ADD StripeSubscriptionId nvarchar(100) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'StripePriceId')
ALTER TABLE dbo.UserSubscription ADD StripePriceId nvarchar(100) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'BillingInterval')
ALTER TABLE dbo.UserSubscription ADD BillingInterval nvarchar(20) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'StripeStatus')
ALTER TABLE dbo.UserSubscription ADD StripeStatus nvarchar(50) NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'CurrentPeriodStartUTC')
ALTER TABLE dbo.UserSubscription ADD CurrentPeriodStartUTC datetime2 NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'CurrentPeriodEndUTC')
ALTER TABLE dbo.UserSubscription ADD CurrentPeriodEndUTC datetime2 NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'CancelAtPeriodEnd')
ALTER TABLE dbo.UserSubscription ADD CancelAtPeriodEnd bit NOT NULL CONSTRAINT DF_UserSubscription_CancelAtPeriodEnd DEFAULT 0;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'CancelledDateUTC')
ALTER TABLE dbo.UserSubscription ADD CancelledDateUTC datetime2 NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'LastPaymentFailureDateUTC')
ALTER TABLE dbo.UserSubscription ADD LastPaymentFailureDateUTC datetime2 NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.UserSubscription') AND name = N'LastStripeEventId')
ALTER TABLE dbo.UserSubscription ADD LastStripeEventId nvarchar(100) NULL;
GO
IF OBJECT_ID(N'dbo.StripeWebhookEvent', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StripeWebhookEvent
(
StripeWebhookEventID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_StripeWebhookEvent PRIMARY KEY,
StripeEventId nvarchar(100) NOT NULL,
EventType nvarchar(100) NOT NULL,
ReceivedDateUTC datetime2 NOT NULL CONSTRAINT DF_StripeWebhookEvent_ReceivedDateUTC DEFAULT SYSUTCDATETIME(),
ProcessedDateUTC datetime2 NULL,
ProcessingStatus nvarchar(50) NOT NULL CONSTRAINT DF_StripeWebhookEvent_ProcessingStatus DEFAULT N'Received',
ErrorMessage nvarchar(1000) NULL,
CONSTRAINT UX_StripeWebhookEvent_EventId UNIQUE (StripeEventId)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserSubscription_StripeSubscriptionId' AND object_id = OBJECT_ID(N'dbo.UserSubscription'))
CREATE INDEX IX_UserSubscription_StripeSubscriptionId ON dbo.UserSubscription(StripeSubscriptionId) WHERE StripeSubscriptionId IS NOT NULL;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserSubscription_StripeCustomerId' AND object_id = OBJECT_ID(N'dbo.UserSubscription'))
CREATE INDEX IX_UserSubscription_StripeCustomerId ON dbo.UserSubscription(StripeCustomerId) WHERE StripeCustomerId IS NOT NULL;
GO
UPDATE dbo.SubscriptionLevel
SET RequiresStripeSubscription = CASE WHEN Name = N'DraftDesk' THEN 0 ELSE 1 END,
StripeMonthlyPriceId = CASE Name
WHEN N'WritingRoom' THEN N'price_1TffeZIH8aCkpHgj4AlC7Atv'
WHEN N'StoryForge' THEN N'price_1TffdJIH8aCkpHgjvQfV7heX'
WHEN N'AuthorStudio' THEN N'price_1TffbaIH8aCkpHgjKFLoaHMJ'
ELSE NULL
END,
StripeAnnualPriceId = CASE Name
WHEN N'WritingRoom' THEN N'price_1Tffe8IH8aCkpHgj25QRMu4t'
WHEN N'StoryForge' THEN N'price_1TffccIH8aCkpHgjXlSwB4A1'
WHEN N'AuthorStudio' THEN N'price_1TffaRIH8aCkpHgjIoQqgwBH'
ELSE NULL
END,
ModifiedDateUTC = SYSUTCDATETIME();
GO
CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_ListActive
AS
BEGIN
SET NOCOUNT ON;
SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook,
MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive,
SortOrder, CreatedDateUTC, ModifiedDateUTC, StripeMonthlyPriceId, StripeAnnualPriceId,
RequiresStripeSubscription
FROM dbo.SubscriptionLevel
WHERE IsActive = 1
ORDER BY SortOrder, DisplayName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_Get
@SubscriptionLevelID int
AS
BEGIN
SET NOCOUNT ON;
SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook,
MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive,
SortOrder, CreatedDateUTC, ModifiedDateUTC, StripeMonthlyPriceId, StripeAnnualPriceId,
RequiresStripeSubscription
FROM dbo.SubscriptionLevel
WHERE SubscriptionLevelID = @SubscriptionLevelID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SubscriptionLevel_GetByStripePrice
@StripePriceId nvarchar(100)
AS
BEGIN
SET NOCOUNT ON;
SELECT SubscriptionLevelID, Name, DisplayName, Description, MaxBooks, MaxChaptersPerBook,
MaxScenesPerBook, MaxCharactersPerProject, MaxStorageMB, MaxCollaborators, IsActive,
SortOrder, CreatedDateUTC, ModifiedDateUTC, StripeMonthlyPriceId, StripeAnnualPriceId,
RequiresStripeSubscription,
CASE
WHEN StripeMonthlyPriceId = @StripePriceId THEN N'Monthly'
WHEN StripeAnnualPriceId = @StripePriceId THEN N'Annual'
ELSE NULL
END AS BillingInterval
FROM dbo.SubscriptionLevel
WHERE IsActive = 1
AND RequiresStripeSubscription = 1
AND (StripeMonthlyPriceId = @StripePriceId OR StripeAnnualPriceId = @StripePriceId);
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_GetCurrent
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT us.UserSubscriptionID, us.UserID, us.SubscriptionLevelID, us.StartDateUTC, us.EndDateUTC,
us.IsActive, us.IsTrial, us.TrialExpiryDateUTC, us.CreatedDateUTC, us.ModifiedDateUTC,
us.StripeCustomerId, us.StripeSubscriptionId, us.StripePriceId, us.BillingInterval, us.StripeStatus,
us.CurrentPeriodStartUTC, us.CurrentPeriodEndUTC, us.CancelAtPeriodEnd, us.CancelledDateUTC,
us.LastPaymentFailureDateUTC, us.LastStripeEventId,
sl.Name, sl.DisplayName, sl.Description, sl.MaxBooks, sl.MaxChaptersPerBook,
sl.MaxScenesPerBook, sl.MaxCharactersPerProject, sl.MaxStorageMB, sl.MaxCollaborators,
sl.IsActive AS SubscriptionLevelIsActive, sl.SortOrder, sl.StripeMonthlyPriceId,
sl.StripeAnnualPriceId, sl.RequiresStripeSubscription
FROM dbo.UserSubscription us
INNER JOIN dbo.SubscriptionLevel sl ON sl.SubscriptionLevelID = us.SubscriptionLevelID
WHERE us.UserID = @UserID
AND us.IsActive = 1
AND (us.EndDateUTC IS NULL OR us.EndDateUTC > SYSUTCDATETIME());
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_SetStripeCustomer
@UserID int,
@StripeCustomerId nvarchar(100)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserSubscription
SET StripeCustomerId = @StripeCustomerId,
ModifiedDateUTC = SYSUTCDATETIME()
WHERE UserID = @UserID
AND IsActive = 1;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StripeWebhookEvent_TryBegin
@StripeEventId nvarchar(100),
@EventType nvarchar(100)
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM dbo.StripeWebhookEvent WHERE StripeEventId = @StripeEventId)
BEGIN
SELECT CAST(0 AS bit) AS ShouldProcess;
RETURN;
END;
INSERT dbo.StripeWebhookEvent (StripeEventId, EventType)
VALUES (@StripeEventId, @EventType);
SELECT CAST(1 AS bit) AS ShouldProcess;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StripeWebhookEvent_Complete
@StripeEventId nvarchar(100),
@ProcessingStatus nvarchar(50),
@ErrorMessage nvarchar(1000) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StripeWebhookEvent
SET ProcessingStatus = @ProcessingStatus,
ProcessedDateUTC = SYSUTCDATETIME(),
ErrorMessage = @ErrorMessage
WHERE StripeEventId = @StripeEventId;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_ApplyStripeState
@UserID int = NULL,
@SubscriptionLevelID int = NULL,
@StripeCustomerId nvarchar(100),
@StripeSubscriptionId nvarchar(100),
@StripePriceId nvarchar(100),
@BillingInterval nvarchar(20),
@StripeStatus nvarchar(50),
@CurrentPeriodStartUTC datetime2 = NULL,
@CurrentPeriodEndUTC datetime2 = NULL,
@CancelAtPeriodEnd bit = 0,
@LastStripeEventId nvarchar(100) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @SubscriptionLevelID IS NULL
SELECT @SubscriptionLevelID = SubscriptionLevelID
FROM dbo.SubscriptionLevel
WHERE IsActive = 1
AND (StripeMonthlyPriceId = @StripePriceId OR StripeAnnualPriceId = @StripePriceId);
IF @BillingInterval IS NULL
SELECT @BillingInterval = CASE
WHEN StripeMonthlyPriceId = @StripePriceId THEN N'Monthly'
WHEN StripeAnnualPriceId = @StripePriceId THEN N'Annual'
ELSE NULL
END
FROM dbo.SubscriptionLevel
WHERE SubscriptionLevelID = @SubscriptionLevelID;
IF @UserID IS NULL
SELECT TOP (1) @UserID = UserID
FROM dbo.UserSubscription
WHERE StripeSubscriptionId = @StripeSubscriptionId
OR StripeCustomerId = @StripeCustomerId
ORDER BY IsActive DESC, ModifiedDateUTC DESC;
IF @UserID IS NULL OR @SubscriptionLevelID IS NULL
RETURN;
DECLARE @ExistingSubscriptionID int;
SELECT TOP (1) @ExistingSubscriptionID = UserSubscriptionID
FROM dbo.UserSubscription
WHERE UserID = @UserID
AND IsActive = 1
ORDER BY UserSubscriptionID DESC;
IF @ExistingSubscriptionID IS NULL
BEGIN
INSERT dbo.UserSubscription
(
UserID, SubscriptionLevelID, StartDateUTC, EndDateUTC, IsActive, IsTrial, TrialExpiryDateUTC,
StripeCustomerId, StripeSubscriptionId, StripePriceId, BillingInterval, StripeStatus,
CurrentPeriodStartUTC, CurrentPeriodEndUTC, CancelAtPeriodEnd, LastStripeEventId
)
VALUES
(
@UserID, @SubscriptionLevelID, COALESCE(@CurrentPeriodStartUTC, SYSUTCDATETIME()), NULL, 1, 0, NULL,
@StripeCustomerId, @StripeSubscriptionId, @StripePriceId, @BillingInterval, @StripeStatus,
@CurrentPeriodStartUTC, @CurrentPeriodEndUTC, @CancelAtPeriodEnd, @LastStripeEventId
);
END
ELSE
BEGIN
UPDATE dbo.UserSubscription
SET SubscriptionLevelID = @SubscriptionLevelID,
EndDateUTC = NULL,
IsActive = 1,
IsTrial = 0,
TrialExpiryDateUTC = NULL,
StripeCustomerId = @StripeCustomerId,
StripeSubscriptionId = @StripeSubscriptionId,
StripePriceId = @StripePriceId,
BillingInterval = @BillingInterval,
StripeStatus = @StripeStatus,
CurrentPeriodStartUTC = @CurrentPeriodStartUTC,
CurrentPeriodEndUTC = @CurrentPeriodEndUTC,
CancelAtPeriodEnd = @CancelAtPeriodEnd,
CancelledDateUTC = CASE WHEN @StripeStatus = N'canceled' THEN COALESCE(CancelledDateUTC, SYSUTCDATETIME()) ELSE NULL END,
LastPaymentFailureDateUTC = CASE WHEN @StripeStatus IN (N'active', N'trialing') THEN NULL ELSE LastPaymentFailureDateUTC END,
LastStripeEventId = @LastStripeEventId,
ModifiedDateUTC = SYSUTCDATETIME()
WHERE UserSubscriptionID = @ExistingSubscriptionID;
END;
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_DowngradeToDraftDesk
@StripeCustomerId nvarchar(100) = NULL,
@StripeSubscriptionId nvarchar(100) = NULL,
@LastStripeEventId nvarchar(100) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @DraftDeskID int;
SELECT @DraftDeskID = SubscriptionLevelID FROM dbo.SubscriptionLevel WHERE Name = N'DraftDesk';
IF @DraftDeskID IS NULL
RETURN;
UPDATE dbo.UserSubscription
SET SubscriptionLevelID = @DraftDeskID,
EndDateUTC = NULL,
IsActive = 1,
IsTrial = 0,
TrialExpiryDateUTC = NULL,
StripeStatus = N'canceled',
CancelAtPeriodEnd = 0,
CancelledDateUTC = COALESCE(CancelledDateUTC, SYSUTCDATETIME()),
LastStripeEventId = @LastStripeEventId,
ModifiedDateUTC = SYSUTCDATETIME()
WHERE (@StripeSubscriptionId IS NOT NULL AND StripeSubscriptionId = @StripeSubscriptionId)
OR (@StripeCustomerId IS NOT NULL AND StripeCustomerId = @StripeCustomerId);
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_RecordPaymentFailure
@StripeCustomerId nvarchar(100) = NULL,
@StripeSubscriptionId nvarchar(100) = NULL,
@LastStripeEventId nvarchar(100) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserSubscription
SET LastPaymentFailureDateUTC = SYSUTCDATETIME(),
LastStripeEventId = @LastStripeEventId,
ModifiedDateUTC = SYSUTCDATETIME()
WHERE (@StripeSubscriptionId IS NOT NULL AND StripeSubscriptionId = @StripeSubscriptionId)
OR (@StripeCustomerId IS NOT NULL AND StripeCustomerId = @StripeCustomerId);
END;
GO
CREATE OR ALTER PROCEDURE dbo.UserSubscription_ClearPaymentFailure
@StripeCustomerId nvarchar(100) = NULL,
@StripeSubscriptionId nvarchar(100) = NULL,
@LastStripeEventId nvarchar(100) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.UserSubscription
SET LastPaymentFailureDateUTC = NULL,
LastStripeEventId = @LastStripeEventId,
ModifiedDateUTC = SYSUTCDATETIME()
WHERE (@StripeSubscriptionId IS NOT NULL AND StripeSubscriptionId = @StripeSubscriptionId)
OR (@StripeCustomerId IS NOT NULL AND StripeCustomerId = @StripeCustomerId);
END;
GO

View File

@ -45,7 +45,11 @@
</div>
<div class="col-md-3">
<p class="eyebrow">Status</p>
<p>@(subscription.IsActive ? "Active" : "Inactive")</p>
<p>@(string.IsNullOrWhiteSpace(subscription.StripeStatus) ? (subscription.IsActive ? "Active" : "Inactive") : subscription.StripeStatus)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Billing interval</p>
<p>@(string.IsNullOrWhiteSpace(subscription.BillingInterval) ? "Not set" : subscription.BillingInterval)</p>
</div>
<div class="col-md-3">
<p class="eyebrow">Trial</p>
@ -65,12 +69,30 @@
</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>
@if (subscription.CancelAtPeriodEnd)
{
<div class="alert alert-warning mt-3">This subscription is scheduled to cancel at the end of the current billing period.</div>
}
@if (subscription.LastPaymentFailureDateUTC.HasValue)
{
<div class="alert alert-warning mt-3">Stripe has reported a payment issue for this subscription.</div>
}
<div class="button-row mt-3">
@if (!string.IsNullOrWhiteSpace(subscription.StripeCustomerId))
{
<form asp-action="ManageBilling" method="post">
<button class="btn btn-outline-secondary" type="submit">Manage Billing</button>
</form>
}
@if (!string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId) && !subscription.CancelAtPeriodEnd)
{
<form asp-action="CancelSubscription" method="post" data-confirm-message="Cancel this subscription at the end of the current billing period?&#10;&#10;Your existing data will remain available.">
<button class="btn btn-outline-danger" type="submit">Cancel Subscription</button>
</form>
}
</div>
}
</section>
<section class="edit-panel">
@ -150,11 +172,31 @@
<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>
@if (!level.RequiresStripeSubscription)
{
<p class="muted">Draft Desk is managed inside PlotWeaver Studio.</p>
}
else
{
<div class="button-row compact-buttons">
@if (!string.IsNullOrWhiteSpace(level.StripeMonthlyPriceId))
{
<form asp-action="SubscriptionCheckout" method="post">
<input type="hidden" name="SubscriptionLevelID" value="@level.SubscriptionLevelID" />
<input type="hidden" name="BillingInterval" value="Monthly" />
<button class="btn @(isCurrent && string.Equals(subscription?.BillingInterval, "Monthly", StringComparison.OrdinalIgnoreCase) ? "btn-outline-secondary" : "btn-outline-primary") btn-sm" type="submit">Monthly</button>
</form>
}
@if (!string.IsNullOrWhiteSpace(level.StripeAnnualPriceId))
{
<form asp-action="SubscriptionCheckout" method="post">
<input type="hidden" name="SubscriptionLevelID" value="@level.SubscriptionLevelID" />
<input type="hidden" name="BillingInterval" value="Annual" />
<button class="btn @(isCurrent && string.Equals(subscription?.BillingInterval, "Annual", StringComparison.OrdinalIgnoreCase) ? "btn-outline-secondary" : "btn-outline-primary") btn-sm" type="submit">Annual</button>
</form>
}
</div>
}
</article>
}
</div>