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/donations")] public sealed class DonationsController( DonationStore donations, IStripeDonationGateway stripe, IConfiguration configuration, ILogger logger) : ControllerBase { [HttpPost("checkout"), Authorize(Roles = "USER,SUPER")] public async Task CreateCheckout(CreateDonationCheckoutRequest request, CancellationToken cancellationToken) { if (DonationValidator.ValidateAmount(request.AmountCents) is { } error) return BadRequest(error); var donation = await donations.CreateAsync(UserId(), request.AmountCents); try { var checkout = await stripe.CreateCheckoutAsync(donation.Id!, donation.AmountCents, cancellationToken); await donations.AttachCheckoutSessionAsync(donation.Id!, checkout.SessionId); return StatusCode(StatusCodes.Status201Created, new DonationCheckoutResponse { DonationId = donation.Id!, CheckoutUrl = checkout.CheckoutUrl, AmountCents = donation.AmountCents }); } catch (Exception exception) { await donations.MarkCheckoutCreationFailedAsync(donation.Id!, exception is StripeException ? "stripe_error" : "configuration_error"); logger.LogError(exception, "Unable to create Stripe Checkout Session for donation {DonationId}", donation.Id); return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Donation checkout unavailable", detail: "The secure checkout could not be started. Please try again."); } } [HttpGet("{id}"), Authorize(Roles = "USER,SUPER")] public async Task GetStatus(string id) => await donations.GetOwnedAsync(id.Trim(), UserId()) is { } donation ? Ok(DonationStatusResponse.FromModel(donation)) : NotFound(); [HttpPost("stripe-webhook"), AllowAnonymous] public async Task StripeWebhook(CancellationToken cancellationToken) { var webhookSecret = configuration["Stripe:WebhookSecret"]; 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 Stripe 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" => DonationStatus.Paid, "checkout.session.async_payment_succeeded" => DonationStatus.Paid, "checkout.session.async_payment_failed" => DonationStatus.Failed, "checkout.session.expired" => DonationStatus.Expired, _ => "" }; if (status.Length == 0) return Ok(); var donationId = session.ClientReferenceId; if (string.IsNullOrWhiteSpace(donationId) && session.Metadata.TryGetValue("donation_id", out var metadataId)) donationId = metadataId; var applied = await donations.ApplyStripeStatusAsync( donationId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "", status, session.PaymentIntentId ?? "", stripeEvent.Id); if (!applied) logger.LogWarning("Stripe event {EventId} did not match donation {DonationId}", stripeEvent.Id, donationId); return Ok(); } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; }