feat: normalize subscription architecture, integrate pgvector, and implement Stripe webhook subscription management.

This commit is contained in:
2026-05-05 15:07:48 +02:00
parent e21c24b66d
commit 311eaa8b04
29 changed files with 1699 additions and 199 deletions
+48 -29
View File
@@ -2,6 +2,7 @@ using NexusReader.Web.Components;
using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
using NexusReader.Web.Client.Services;
using NexusReader.UI.Shared.Services;
using NexusReader.Domain.Entities;
@@ -67,7 +68,7 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
// Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"))
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName))
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
// Billing & Stripe
@@ -245,8 +246,6 @@ knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request,
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
{
var result = await knowledgeService.ClearCacheAsync();
@@ -256,8 +255,13 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg);
});
app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusUser> userManager, IConfiguration configuration) =>
app.MapPost("/api/StripeWebhook", async (
HttpContext context,
UserManager<NexusUser> userManager,
IConfiguration configuration,
IDbContextFactory<AppDbContext> dbContextFactory) =>
{
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
var webhookSecret = configuration["Stripe:WebhookSecret"] ?? "";
@@ -273,20 +277,19 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
{
case EventTypes.CheckoutSessionCompleted:
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager);
await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext);
break;
case EventTypes.CustomerSubscriptionUpdated:
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager);
await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext);
break;
case EventTypes.CustomerSubscriptionDeleted:
var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager);
await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext);
break;
}
return Results.Ok();
}
catch (StripeException e)
@@ -295,36 +298,43 @@ app.MapPost("/api/StripeWebhook", async (HttpContext context, UserManager<NexusU
}
});
async Task HandleSubscriptionSuccess(string? email, Dictionary<string, string>? metadata, UserManager<NexusUser> userManager)
async Task HandleSubscriptionSuccess(
string? email,
Dictionary<string, string>? metadata,
UserManager<NexusUser> userManager,
AppDbContext dbContext)
{
if (string.IsNullOrEmpty(email)) return;
var user = await userManager.FindByEmailAsync(email);
if (user != null)
{
var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro";
var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName;
var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName);
user.CurrentPlan = plan;
user.AITokenLimit = plan.ToLower() switch
if (plan != null)
{
"pro" => 50000,
"enterprise" => 500000,
_ => 10000 // default for unknown or free
};
user.SubscriptionPlanId = plan.Id;
user.AITokenLimit = plan.AITokenLimit;
}
await userManager.UpdateAsync(user);
}
}
async Task HandleSubscriptionCancellation(string? email, UserManager<NexusUser> userManager)
async Task HandleSubscriptionCancellation(
string? email,
UserManager<NexusUser> userManager,
AppDbContext dbContext)
{
if (string.IsNullOrEmpty(email)) return;
var user = await userManager.FindByEmailAsync(email);
if (user != null)
{
user.CurrentPlan = "Free";
user.AITokenLimit = 5000; // Free tier limit
var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId);
user.SubscriptionPlanId = SubscriptionPlan.FreeId;
user.AITokenLimit = freePlan?.AITokenLimit ?? 5000;
await userManager.UpdateAsync(user);
}
}
@@ -359,7 +369,6 @@ app.MapGet("/identity/callback/google", async (
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null)
{
// TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning
var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true };
var createResult = await userManager.CreateAsync(user);
if (createResult.Succeeded)
@@ -373,22 +382,32 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=ProvisioningFailed");
});
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, AppDbContext dbContext) =>
app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager<NexusUser> userManager, IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var profile = await dbContext.Users
.Where(u => u.Id == userId)
.Select(u => new
.Select(u => new UserProfileDto
{
u.Email,
u.AITokenLimit,
u.AITokensUsed,
u.CurrentPlan,
u.TenantId,
AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0,
LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None"
Email = u.Email ?? string.Empty,
AITokensUsed = u.AITokensUsed,
Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto
{
Id = u.SubscriptionPlan.Id,
Name = u.SubscriptionPlan.PlanName,
AITokenLimit = u.SubscriptionPlan.AITokenLimit,
MonthlyPrice = u.SubscriptionPlan.MonthlyPrice
} : new SubscriptionPlanDto(),
AverageQuizScore = u.QuizResults.Any() ? (int)u.QuizResults.Average(q => q.Percentage) : 0,
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{
Id = e.Id,
Title = e.Title
}).FirstOrDefault()
})
.FirstOrDefaultAsync();