Add freemium access economy and token store

This commit is contained in:
hz
2026-07-27 22:20:21 -05:00
parent 1d5d03a4f6
commit 4bbac27b84
40 changed files with 1807 additions and 37 deletions
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/access")]
[Authorize(Roles = "USER,SUPER")]
public sealed class AccessController(
AccessStore access,
ExamSimulationStore simulations,
RatingStore ratings,
TimeProvider clock) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() =>
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
[HttpPost("redeem-credits")]
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
{
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
return status is null
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
: Ok(status);
}
[HttpPost("activate-token")]
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
{
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
return BadRequest("token must be day, week, or month");
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
return status is null
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
: Ok(status);
}
[HttpPost("qbank-reset")]
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
{
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
if (await simulations.FindActiveAsync(UserId()) is not null)
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
if (cycle is null)
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
}
[HttpPost("steam-checkouts")]
public IActionResult SteamCheckout() => Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Steam Wallet checkout is not configured",
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
private static bool ValidOperationId(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Trim().Length <= 100;
}
@@ -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();
}
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
public class ExamSimulationsController(ExamSimulationStore simulations, AccessStore access) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
[HttpPost]
public async Task<IActionResult> Create()
{
var operationId = $"exam-create:{Guid.NewGuid():N}";
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
return StatusCode(StatusCodes.Status402PaymentRequired, new
{
code = "exam_token_required",
message = "An exam simulation token is required."
});
try
{
var simulation = await simulations.CreateAsync(UserId());
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
catch (InvalidOperationException exception)
{
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
return Conflict(exception.Message);
}
catch
{
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
throw;
}
}
[HttpGet("preview-results")]
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly AccessStore _access;
public QuestionsController(
QuestionStore questions,
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue)
ReviewQueueStore reviewQueue,
AccessStore access)
{
_questions = questions;
_attempts = attempts;
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_access = access;
}
[HttpGet("systems")]
@@ -52,6 +55,13 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
{
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = "qbank_limit_reached",
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
access = await _access.GetStatusAsync(UserId())
});
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
@@ -117,6 +127,8 @@ public class QuestionsController : ControllerBase
return BadRequest("questionExternalId required");
if (string.IsNullOrWhiteSpace(req.ChoiceId))
return BadRequest("choiceId required");
if (string.IsNullOrWhiteSpace(req.CompletionId) || req.CompletionId.Trim().Length > 100)
return BadRequest("completionId required and must be 100 characters or fewer");
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
@@ -146,6 +158,15 @@ public class QuestionsController : ControllerBase
?? "";
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var completionId = req.CompletionId.Trim();
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = "qbank_limit_reached",
message = "Your free QBank allowance is exhausted.",
access = await _access.GetStatusAsync(userId)
});
var bankCycle = await _access.GetBankCycleAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
@@ -154,7 +175,9 @@ public class QuestionsController : ControllerBase
req.ChoiceId.Trim() == question.CorrectChoiceId,
false,
presentedChoiceIds,
req.DurationSeconds);
req.DurationSeconds,
bankCycle,
completionId);
if (record.Inserted)
{
_metrics.AttemptCreated();
@@ -172,6 +195,8 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && !User.IsInRole("SUPER"))
await _access.TryAwardCreditAsync(userId, $"qbank-credit:{record.Attempt.Id}");
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -204,7 +229,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
});
}
@@ -252,7 +278,7 @@ public class QuestionsController : ControllerBase
[HttpPost("{externalId}/skip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Skip(string externalId)
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
{
if (string.IsNullOrWhiteSpace(externalId))
return BadRequest("externalId required");
@@ -264,13 +290,28 @@ public class QuestionsController : ControllerBase
var userId = CurrentUserId();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
if (string.IsNullOrWhiteSpace(completionId) || completionId.Trim().Length > 100)
return BadRequest("completionId required and must be 100 characters or fewer");
completionId = completionId.Trim();
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = "qbank_limit_reached",
message = "Your free QBank allowance is exhausted.",
access = await _access.GetStatusAsync(userId)
});
var bankCycle = await _access.GetBankCycleAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
"",
false,
true);
true,
null,
0,
bankCycle,
completionId);
if (record.Inserted)
_metrics.AttemptCreated();
else
@@ -304,7 +345,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
});
}