PlotDirector/PlotLine/Services/StripeBillingService.cs

382 lines
17 KiB
C#

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