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.
This commit is contained in:
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user