235 lines
11 KiB
C#
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;
|
|
}
|
|
}
|