Phase 5I – Stripe Integration Completed
This commit is contained in:
parent
300bdcdca1
commit
1e03241d4e
@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
using PlotLine.ViewModels;
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
@ -13,7 +14,8 @@ public sealed class ManageAccountController(
|
|||||||
IUserRepository users,
|
IUserRepository users,
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
ITwoFactorService twoFactorService,
|
ITwoFactorService twoFactorService,
|
||||||
ISubscriptionService subscriptions) : Controller
|
ISubscriptionService subscriptions,
|
||||||
|
IStripeBillingService stripeBilling) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
@ -58,6 +60,84 @@ public sealed class ManageAccountController(
|
|||||||
return RedirectToAction(nameof(Subscription));
|
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]
|
[HttpGet]
|
||||||
public IActionResult ChangePassword()
|
public IActionResult ChangePassword()
|
||||||
{
|
{
|
||||||
|
|||||||
20
PlotLine/Controllers/StripeWebhookController.cs
Normal file
20
PlotLine/Controllers/StripeWebhookController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,14 @@ public interface ISubscriptionRepository
|
|||||||
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);
|
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
|
public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFactory) : ISubscriptionRepository
|
||||||
@ -99,4 +107,76 @@ public sealed class SubscriptionRepository(ISqlConnectionFactory connectionFacto
|
|||||||
new { UserID = userId },
|
new { UserID = userId },
|
||||||
commandType: CommandType.StoredProcedure);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
PlotLine/Models/StripeSettings.cs
Normal file
8
PlotLine/Models/StripeSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -16,6 +16,10 @@ public sealed class SubscriptionLevel
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public DateTime CreatedDateUTC { get; set; }
|
public DateTime CreatedDateUTC { get; set; }
|
||||||
public DateTime ModifiedDateUTC { 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
|
public class UserSubscription
|
||||||
@ -30,6 +34,17 @@ public class UserSubscription
|
|||||||
public DateTime? TrialExpiryDateUTC { get; set; }
|
public DateTime? TrialExpiryDateUTC { get; set; }
|
||||||
public DateTime CreatedDateUTC { get; set; }
|
public DateTime CreatedDateUTC { get; set; }
|
||||||
public DateTime ModifiedDateUTC { 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
|
public sealed class UserSubscriptionDetail : UserSubscription
|
||||||
@ -80,3 +95,24 @@ public sealed class SubscriptionUsageSummary
|
|||||||
public long StorageUsedBytes { get; set; }
|
public long StorageUsedBytes { get; set; }
|
||||||
public long StorageMaximumBytes { 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";
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
<PackageReference Include="Dapper" Version="2.1.79" />
|
<PackageReference Include="Dapper" Version="2.1.79" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="52.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -32,6 +32,7 @@ public class Program
|
|||||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
});
|
});
|
||||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||||
|
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
||||||
builder.Services.AddDataProtection()
|
builder.Services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
@ -136,6 +137,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IImportService, ImportService>();
|
builder.Services.AddScoped<IImportService, ImportService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
builder.Services.AddSingleton<IHelpService, HelpService>();
|
builder.Services.AddSingleton<IHelpService, HelpService>();
|
||||||
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
|
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
|||||||
381
PlotLine/Services/StripeBillingService.cs
Normal file
381
PlotLine/Services/StripeBillingService.cs
Normal 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);
|
||||||
|
}
|
||||||
379
PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql
Normal file
379
PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql
Normal 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
|
||||||
@ -45,7 +45,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<p class="eyebrow">Status</p>
|
<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>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<p class="eyebrow">Trial</p>
|
<p class="eyebrow">Trial</p>
|
||||||
@ -65,11 +69,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form asp-action="SubscriptionPlaceholder" method="post" class="button-row mt-3">
|
@if (subscription.CancelAtPeriodEnd)
|
||||||
<button class="btn btn-outline-primary" type="submit">Upgrade</button>
|
{
|
||||||
<button class="btn btn-outline-secondary" type="submit">Downgrade</button>
|
<div class="alert alert-warning mt-3">This subscription is scheduled to cancel at the end of the current billing period.</div>
|
||||||
<button class="btn btn-outline-secondary" type="submit">Manage Billing</button>
|
}
|
||||||
</form>
|
@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? Your existing data will remain available.">
|
||||||
|
<button class="btn btn-outline-danger" type="submit">Cancel Subscription</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -150,11 +172,31 @@
|
|||||||
<dt class="col-7">Collaborators</dt>
|
<dt class="col-7">Collaborators</dt>
|
||||||
<dd class="col-5">@FormatLimit(level.MaxCollaborators)</dd>
|
<dd class="col-5">@FormatLimit(level.MaxCollaborators)</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form asp-action="SubscriptionPlaceholder" method="post">
|
@if (!level.RequiresStripeSubscription)
|
||||||
<button class="btn @(isCurrent ? "btn-outline-secondary" : "btn-outline-primary") btn-sm" type="submit">
|
{
|
||||||
@(isCurrent ? "Manage Billing" : "Change Plan")
|
<p class="muted">Draft Desk is managed inside PlotWeaver Studio.</p>
|
||||||
</button>
|
}
|
||||||
</form>
|
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>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user