feat: normalize subscription architecture, integrate pgvector, and implement Stripe webhook subscription management.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user