using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using Stripe.Checkout; namespace OTSSignsOrchestrator.Server.Api; public static class SignupApi { public static void MapSignupEndpoints(this WebApplication app) { app.MapPost("/api/signup/initiate", HandleInitiate) .RequireRateLimiting("signup"); app.MapGet("/api/signup/status/{token:guid}", HandleStatus); } private static async Task HandleInitiate( SignupRequest req, OrchestratorDbContext db, IConfiguration config, ILogger logger) { // ── Validation ────────────────────────────────────────────────────── var errors = new List(); if (string.IsNullOrWhiteSpace(req.CompanyName)) errors.Add("companyName is required."); if (string.IsNullOrWhiteSpace(req.AdminEmail) || !new EmailAddressAttribute().IsValid(req.AdminEmail)) errors.Add("A valid adminEmail is required."); if (string.IsNullOrWhiteSpace(req.Plan) || !req.Plan.Equals("Essentials", StringComparison.OrdinalIgnoreCase) && !req.Plan.Equals("Pro", StringComparison.OrdinalIgnoreCase)) errors.Add("plan must be 'Essentials' or 'Pro'."); if (req.ScreenCount < 1) errors.Add("screenCount must be at least 1."); if (req.Plan?.Equals("Essentials", StringComparison.OrdinalIgnoreCase) == true && req.ScreenCount > 50) errors.Add("Essentials plan supports a maximum of 50 screens."); if (string.IsNullOrWhiteSpace(req.BillingFrequency) || !req.BillingFrequency.Equals("monthly", StringComparison.OrdinalIgnoreCase) && !req.BillingFrequency.Equals("annual", StringComparison.OrdinalIgnoreCase)) errors.Add("billingFrequency must be 'monthly' or 'annual'."); if (errors.Count > 0) return Results.ValidationProblem( errors.ToDictionary(e => e, _ => new[] { "Validation failed." })); // ── Create pending customer ───────────────────────────────────────── var plan = Enum.Parse(req.Plan!, true); var customer = new Customer { Id = Guid.NewGuid(), CompanyName = req.CompanyName!.Trim(), AdminEmail = req.AdminEmail!.Trim().ToLowerInvariant(), AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty, AdminLastName = req.AdminLastName?.Trim() ?? string.Empty, Plan = plan, ScreenCount = req.ScreenCount, Status = CustomerStatus.PendingPayment, CreatedAt = DateTime.UtcNow, }; db.Customers.Add(customer); await db.SaveChangesAsync(); // ── Stripe Checkout Session ───────────────────────────────────────── var priceKey = $"Stripe:Prices:{req.Plan}:{req.BillingFrequency}".ToLowerInvariant(); var priceId = config[priceKey]; if (string.IsNullOrWhiteSpace(priceId)) { logger.LogError("Stripe price ID not configured for key {PriceKey}", priceKey); return Results.Problem("Billing configuration error. Contact support.", statusCode: 500); } var sessionOptions = new SessionCreateOptions { Mode = "subscription", CustomerEmail = customer.AdminEmail, LineItems = new List { new() { Price = priceId, Quantity = req.ScreenCount, }, }, SubscriptionData = new SessionSubscriptionDataOptions { TrialPeriodDays = 14, }, Metadata = new Dictionary { ["ots_customer_id"] = customer.Id.ToString(), ["company_name"] = customer.CompanyName, ["admin_email"] = customer.AdminEmail, ["admin_first_name"] = customer.AdminFirstName, ["admin_last_name"] = customer.AdminLastName, ["plan"] = req.Plan!, ["screen_count"] = req.ScreenCount.ToString(), ["billing_frequency"] = req.BillingFrequency!, }, SuccessUrl = config["Stripe:SuccessUrl"] ?? "https://app.ots-signs.com/signup/success?session_id={CHECKOUT_SESSION_ID}", CancelUrl = config["Stripe:CancelUrl"] ?? "https://app.ots-signs.com/signup/cancel", }; var sessionService = new SessionService(); var session = await sessionService.CreateAsync(sessionOptions); customer.StripeCheckoutSessionId = session.Id; await db.SaveChangesAsync(); logger.LogInformation( "Signup initiated: customer={CustomerId}, company={Company}, plan={Plan}, screens={Screens}", customer.Id, customer.CompanyName, req.Plan, req.ScreenCount); return Results.Ok(new { checkoutUrl = session.Url, statusToken = customer.Id }); } private static async Task HandleStatus( Guid token, OrchestratorDbContext db) { var customer = await db.Customers .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == token); if (customer is null) return Results.NotFound(); // Find latest provisioning job if any var job = await db.Jobs .AsNoTracking() .Where(j => j.CustomerId == customer.Id && j.JobType == "provision") .OrderByDescending(j => j.CreatedAt) .FirstOrDefaultAsync(); int pctComplete = customer.Status switch { CustomerStatus.PendingPayment => 0, CustomerStatus.Provisioning => job?.Status switch { JobStatus.Running => 50, JobStatus.Completed => 100, _ => 10, }, CustomerStatus.Active => 100, _ => 0, }; return Results.Ok(new { status = customer.Status.ToString(), provisioningStep = job?.Steps .Where(s => s.Status == JobStepStatus.Running) .Select(s => s.StepName) .FirstOrDefault() ?? (customer.Status == CustomerStatus.Active ? "complete" : "waiting"), pctComplete, }); } } public record SignupRequest( string? CompanyName, string? AdminFirstName, string? AdminLastName, string? AdminEmail, string? Phone, string? Plan, int ScreenCount, string? BillingFrequency, string? PromoCode);