Files
jobtrackingapp/JobTrackerApi/Controllers/BillingController.cs
T

235 lines
11 KiB
C#

using System.Security.Claims;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/billing")]
public sealed class BillingController : ControllerBase
{
private const string UserMetadataKey = "jobtracker_user_id";
private readonly IConfiguration _configuration;
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly ILogger<BillingController> _logger;
private readonly ExternalOrigin _externalOrigin;
public BillingController(
IConfiguration configuration,
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
ILogger<BillingController> logger,
ExternalOrigin? externalOrigin = null)
{
_configuration = configuration;
_users = users;
_roles = roles;
_logger = logger;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
}
public sealed record BillingRedirectDto(string Url);
public sealed record BillingStatusDto(bool Enabled, bool CanCheckout, bool CanManage);
[HttpGet("status")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingStatusDto>> Status(CancellationToken cancellationToken)
{
var enabled = TryGetConfiguration(out _, out _, out _, out _);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var entitlements = AccountPlans.ForRoles(await _users.GetRolesAsync(user));
return Ok(new BillingStatusDto(
enabled,
enabled && !entitlements.Ai,
enabled && !string.IsNullOrWhiteSpace(user.StripeCustomerId)));
}
[HttpPost("checkout")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Checkout(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var currentRoles = await _users.GetRolesAsync(user);
if (AccountPlans.ForRoles(currentRoles).Ai)
return Conflict("This account already has Pro access.");
var metadata = new Dictionary<string, string> { [UserMetadataKey] = user.Id };
var options = new Stripe.Checkout.SessionCreateOptions
{
Mode = "subscription",
SuccessUrl = $"{publicBaseUrl}/settings?billing=success",
CancelUrl = $"{publicBaseUrl}/settings?billing=cancelled",
ClientReferenceId = user.Id,
Customer = user.StripeCustomerId,
CustomerEmail = string.IsNullOrWhiteSpace(user.StripeCustomerId) ? user.Email : null,
Metadata = metadata,
SubscriptionData = new Stripe.Checkout.SessionSubscriptionDataOptions { Metadata = metadata },
LineItems = new List<Stripe.Checkout.SessionLineItemOptions>
{
new() { Price = premiumPrice, Quantity = 1 },
},
};
try
{
var session = await new Stripe.Checkout.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(session.Url))
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe checkout creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing checkout is temporarily unavailable.");
}
}
[HttpPost("portal")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Portal(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out _, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.StripeCustomerId)) return NotFound("No Stripe customer exists for this account.");
try
{
var session = await new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = $"{publicBaseUrl}/settings",
}, cancellationToken: cancellationToken);
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe billing portal creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing management is temporarily unavailable.");
}
}
[HttpPost("webhook")]
[AllowAnonymous]
[RequestSizeLimit(1_000_000)]
public async Task<IActionResult> Webhook(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out var webhookSecret, out _))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
string json;
using (var reader = new StreamReader(Request.Body))
json = await reader.ReadToEndAsync(cancellationToken);
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Rejected a Stripe webhook with an invalid signature");
return BadRequest("Invalid Stripe signature.");
}
if (stripeEvent.Type is not (EventTypes.CustomerSubscriptionCreated
or EventTypes.CustomerSubscriptionUpdated
or EventTypes.CustomerSubscriptionDeleted))
return Ok();
if (stripeEvent.Data.Object is not Subscription eventSubscription)
return BadRequest("Stripe subscription payload was missing.");
Subscription subscription;
try
{
// Stripe does not guarantee webhook delivery order. Re-read the subscription so a late
// event cannot restore access after a newer cancellation or payment failure.
subscription = await new SubscriptionService(new StripeClient(secretKey))
.GetAsync(eventSubscription.Id, cancellationToken: cancellationToken);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Could not refresh Stripe subscription {SubscriptionId}", eventSubscription.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Could not verify the current subscription state.");
}
if (subscription.Items?.Data?.Any(item => item.Price?.Id == premiumPrice) != true)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because it does not contain the configured Premium price", subscription.Id);
return Ok();
}
if (!subscription.Metadata.TryGetValue(UserMetadataKey, out var userId) || string.IsNullOrWhiteSpace(userId))
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because Jobbjakt user metadata is missing", subscription.Id);
return Ok();
}
var user = await _users.FindByIdAsync(userId);
if (user is null)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because user {UserId} no longer exists", subscription.Id, userId);
return Ok();
}
user.StripeCustomerId = subscription.CustomerId;
user.StripeSubscriptionId = subscription.Id;
user.StripeSubscriptionStatus = subscription.Status;
user.StripeLastEventCreatedUtc = stripeEvent.Created;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not persist billing state.");
const string premiumRole = "Premium";
if (!await _roles.RoleExistsAsync(premiumRole))
{
var roleResult = await _roles.CreateAsync(new IdentityRole(premiumRole));
if (!roleResult.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not provision the Premium role.");
}
var hasRole = await _users.IsInRoleAsync(user, premiumRole);
var shouldHaveRole = AccountPlans.IsPremiumSubscriptionStatus(subscription.Status);
var roleUpdate = shouldHaveRole && !hasRole
? await _users.AddToRoleAsync(user, premiumRole)
: !shouldHaveRole && hasRole
? await _users.RemoveFromRoleAsync(user, premiumRole)
: IdentityResult.Success;
if (!roleUpdate.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not update Premium access.");
return Ok();
}
private bool TryGetConfiguration(out string secretKey, out string premiumPrice, out string webhookSecret, out string publicBaseUrl)
{
secretKey = (_configuration["Stripe:SecretKey"] ?? string.Empty).Trim();
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
publicBaseUrl = _externalOrigin.BaseUrl;
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0;
}
}