382 lines
17 KiB
C#
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);
|
|
}
|