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 _users; private readonly RoleManager _roles; private readonly ILogger _logger; private readonly ExternalOrigin _externalOrigin; public BillingController( IConfiguration configuration, UserManager users, RoleManager roles, ILogger 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> 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> 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 { [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 { 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> 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 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; } }