- 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.
174 lines
7.0 KiB
C#
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);
|