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 PlotDirector 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); }