Add freemium access economy and token store
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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<AccessPurchasesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("web-checkouts")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> 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<IActionResult> GetPurchase(string id) =>
|
||||
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
|
||||
? Ok(AccessPurchaseStore.ToStatus(purchase))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user