Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Api/SignupApi.cs
Matt Batchelder c6d46098dd feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00

174 lines
7.0 KiB
C#

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<IResult> HandleInitiate(
SignupRequest req,
OrchestratorDbContext db,
IConfiguration config,
ILogger<SignupRequest> logger)
{
// ── Validation ──────────────────────────────────────────────────────
var errors = new List<string>();
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<CustomerPlan>(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<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = req.ScreenCount,
},
},
SubscriptionData = new SessionSubscriptionDataOptions
{
TrialPeriodDays = 14,
},
Metadata = new Dictionary<string, string>
{
["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<IResult> 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);