using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using QuestionsApi.Models; using QuestionsApi.Services; using Stripe; using Stripe.Checkout; using System.Security.Claims; namespace QuestionsApi.Controllers; [ApiController] [Route("api/access")] public sealed class AccessPurchasesController( AccessPurchaseStore purchases, AccessStore access, IStripeAccessGateway stripe, IConfiguration configuration, ILogger logger) : ControllerBase { [HttpPost("web-checkouts")] [Authorize(Roles = "USER,SUPER")] public async Task CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken) { if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product)) return BadRequest("Unknown access product."); var purchase = await purchases.CreateAsync(UserId(), product); try { var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken); await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId); return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse { PurchaseId = purchase.Id!, CheckoutUrl = checkout.CheckoutUrl, Sku = product.Sku, AmountCents = product.AmountCents }); } catch (Exception exception) { await purchases.MarkCheckoutFailedAsync(purchase.Id!); logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id); return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Access checkout unavailable", detail: "The secure checkout could not be started. Please try again."); } } [HttpGet("purchases/{id}")] [Authorize(Roles = "USER,SUPER")] public async Task GetPurchase(string id) => await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase ? Ok(AccessPurchaseStore.ToStatus(purchase)) : NotFound(); [HttpPost("stripe-webhook")] [AllowAnonymous] public async Task StripeWebhook(CancellationToken cancellationToken) { var webhookSecret = configuration["Stripe:AccessWebhookSecret"]; if (string.IsNullOrWhiteSpace(webhookSecret)) return Problem(statusCode: 503, title: "Stripe webhook is not configured"); using var reader = new StreamReader(Request.Body); var json = await reader.ReadToEndAsync(cancellationToken); Event stripeEvent; try { stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret); } catch (StripeException exception) { logger.LogWarning(exception, "Rejected access webhook with an invalid signature"); return BadRequest("Invalid Stripe signature"); } if (stripeEvent.Data.Object is not Session session) return Ok(); var status = stripeEvent.Type switch { "checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid, "checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid, "checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed, "checkout.session.expired" => AccessPurchaseStatus.Expired, _ => "" }; if (status.Length == 0) return Ok(); var purchaseId = session.ClientReferenceId; if (string.IsNullOrWhiteSpace(purchaseId) && session.Metadata.TryGetValue("access_purchase_id", out var metadataId)) purchaseId = metadataId; var purchase = await purchases.ApplyProviderStatusAsync( purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "", status, session.PaymentIntentId ?? "", stripeEvent.Id); if (purchase is null) return Ok(); if (status == AccessPurchaseStatus.Paid && AccessCatalog.Products.TryGetValue(purchase.Sku, out var product)) { await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}"); await purchases.MarkGrantAppliedAsync(purchase.Id!); } return Ok(); } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException(); }