From 1e03241d4ea7ef749e1061e89ea5614496511a9d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 7 Jun 2026 14:59:41 +0100 Subject: [PATCH] =?UTF-8?q?Phase=205I=20=E2=80=93=20Stripe=20Integration?= =?UTF-8?q?=20Completed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ManageAccountController.cs | 82 +++- .../Controllers/StripeWebhookController.cs | 20 + PlotLine/Data/SubscriptionRepository.cs | 80 ++++ PlotLine/Models/StripeSettings.cs | 8 + PlotLine/Models/SubscriptionModels.cs | 36 ++ PlotLine/PlotLine.csproj | 1 + PlotLine/Program.cs | 2 + PlotLine/Services/StripeBillingService.cs | 381 ++++++++++++++++++ .../050_Phase5I_StripePaymentIntegration.sql | 379 +++++++++++++++++ .../Views/ManageAccount/Subscription.cshtml | 64 ++- 10 files changed, 1041 insertions(+), 12 deletions(-) create mode 100644 PlotLine/Controllers/StripeWebhookController.cs create mode 100644 PlotLine/Models/StripeSettings.cs create mode 100644 PlotLine/Services/StripeBillingService.cs create mode 100644 PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql diff --git a/PlotLine/Controllers/ManageAccountController.cs b/PlotLine/Controllers/ManageAccountController.cs index b2192d9..6828898 100644 --- a/PlotLine/Controllers/ManageAccountController.cs +++ b/PlotLine/Controllers/ManageAccountController.cs @@ -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 Index() @@ -58,6 +60,84 @@ public sealed class ManageAccountController( return RedirectToAction(nameof(Subscription)); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 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 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() { diff --git a/PlotLine/Controllers/StripeWebhookController.cs b/PlotLine/Controllers/StripeWebhookController.cs new file mode 100644 index 0000000..2c70487 --- /dev/null +++ b/PlotLine/Controllers/StripeWebhookController.cs @@ -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 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); + } +} diff --git a/PlotLine/Data/SubscriptionRepository.cs b/PlotLine/Data/SubscriptionRepository.cs index f785887..c141bed 100644 --- a/PlotLine/Data/SubscriptionRepository.cs +++ b/PlotLine/Data/SubscriptionRepository.cs @@ -15,6 +15,14 @@ public interface ISubscriptionRepository Task GetCharacterCountForProjectAsync(int projectId); Task GetCollaboratorCountForOwnerAsync(int userId); Task GetUsageSummaryForOwnerAsync(int userId); + Task GetSubscriptionLevelByStripePriceAsync(string stripePriceId); + Task SetStripeCustomerAsync(int userId, string stripeCustomerId); + Task 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 GetSubscriptionLevelByStripePriceAsync(string stripePriceId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "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 TryBeginStripeWebhookEventAsync(string stripeEventId, string eventType) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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); + } } diff --git a/PlotLine/Models/StripeSettings.cs b/PlotLine/Models/StripeSettings.cs new file mode 100644 index 0000000..f3eee0d --- /dev/null +++ b/PlotLine/Models/StripeSettings.cs @@ -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; +} diff --git a/PlotLine/Models/SubscriptionModels.cs b/PlotLine/Models/SubscriptionModels.cs index eb1933c..ba9ba45 100644 --- a/PlotLine/Models/SubscriptionModels.cs +++ b/PlotLine/Models/SubscriptionModels.cs @@ -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"; +} diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index e714d68..a518f60 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -11,6 +11,7 @@ + diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 77eadf3..170c037 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -32,6 +32,7 @@ public class Program options.Cookie.SameSite = SameSiteMode.Lax; }); builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); + builder.Services.Configure(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(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/StripeBillingService.cs b/PlotLine/Services/StripeBillingService.cs new file mode 100644 index 0000000..b60a7e7 --- /dev/null +++ b/PlotLine/Services/StripeBillingService.cs @@ -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 StartCheckoutOrChangePlanAsync(AppUser user, StripeCheckoutRequest request, string successUrl, string cancelUrl); + Task CancelAtPeriodEndAsync(AppUser user); + Task CreateBillingPortalSessionAsync(AppUser user, string returnUrl); + Task 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 stripeOptions, + ILogger logger) : IStripeBillingService +{ + private readonly StripeSettings settings = stripeOptions.Value; + + public async Task 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 + { + ["UserID"] = user.UserID.ToString(), + ["SubscriptionLevelID"] = level.SubscriptionLevelID.ToString(), + ["BillingInterval"] = billingInterval + }, + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + ["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 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 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 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 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 + { + ["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 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 { ["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); +} diff --git a/PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql b/PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql new file mode 100644 index 0000000..e7b0d37 --- /dev/null +++ b/PlotLine/Sql/050_Phase5I_StripePaymentIntegration.sql @@ -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 diff --git a/PlotLine/Views/ManageAccount/Subscription.cshtml b/PlotLine/Views/ManageAccount/Subscription.cshtml index d5a0835..2d42895 100644 --- a/PlotLine/Views/ManageAccount/Subscription.cshtml +++ b/PlotLine/Views/ManageAccount/Subscription.cshtml @@ -45,7 +45,11 @@

Status

-

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

+

@(string.IsNullOrWhiteSpace(subscription.StripeStatus) ? (subscription.IsActive ? "Active" : "Inactive") : subscription.StripeStatus)

+
+
+

Billing interval

+

@(string.IsNullOrWhiteSpace(subscription.BillingInterval) ? "Not set" : subscription.BillingInterval)

Trial

@@ -65,11 +69,29 @@
-
- - - -
+ @if (subscription.CancelAtPeriodEnd) + { +
This subscription is scheduled to cancel at the end of the current billing period.
+ } + @if (subscription.LastPaymentFailureDateUTC.HasValue) + { +
Stripe has reported a payment issue for this subscription.
+ } + +
+ @if (!string.IsNullOrWhiteSpace(subscription.StripeCustomerId)) + { +
+ +
+ } + @if (!string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId) && !subscription.CancelAtPeriodEnd) + { +
+ +
+ } +
} @@ -150,11 +172,31 @@
Collaborators
@FormatLimit(level.MaxCollaborators)
-
- -
+ @if (!level.RequiresStripeSubscription) + { +

Draft Desk is managed inside PlotWeaver Studio.

+ } + else + { +
+ @if (!string.IsNullOrWhiteSpace(level.StripeMonthlyPriceId)) + { +
+ + + +
+ } + @if (!string.IsNullOrWhiteSpace(level.StripeAnnualPriceId)) + { +
+ + + +
+ } +
+ } }