Add Stripe donation flow

This commit is contained in:
hz
2026-07-19 22:26:33 -05:00
parent f12904bf73
commit dec5e74aa7
30 changed files with 1053 additions and 1 deletions
@@ -0,0 +1,86 @@
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<DonationsController> logger) : ControllerBase
{
[HttpPost("checkout"), Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> 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<IActionResult> GetStatus(string id) =>
await donations.GetOwnedAsync(id.Trim(), UserId()) is { } donation
? Ok(DonationStatusResponse.FromModel(donation))
: NotFound();
[HttpPost("stripe-webhook"), AllowAnonymous]
public async Task<IActionResult> 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 ?? "";
}