Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/RegistrationController.cs
T

920 lines
44 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Registration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles new tenant company sign-up via two paths: a free-trial path (immediate account creation)
/// and a paid path (Stripe Checkout session → payment → account creation on return). A
/// <c>PendingRegistrationSession</c> persisted to the database bridges the Stripe redirect gap so
/// registration data survives even if the user's browser session is lost between leaving for
/// Stripe and returning.
/// </summary>
[AllowAnonymous]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Registration)]
public class RegistrationController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _db;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ISeedDataService _seedDataService;
private readonly IAdminNotificationService _adminNotification;
private readonly IInAppNotificationService _inApp;
private readonly IPlatformSettingsService _platformSettings;
private readonly IStripeService _stripeService;
private readonly IEmailService _emailService;
private readonly ILogger<RegistrationController> _logger;
public RegistrationController(
IUnitOfWork unitOfWork,
ApplicationDbContext db,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ISeedDataService seedDataService,
IAdminNotificationService adminNotification,
IInAppNotificationService inApp,
IPlatformSettingsService platformSettings,
IStripeService stripeService,
IEmailService emailService,
ILogger<RegistrationController> logger)
{
_unitOfWork = unitOfWork;
_db = db;
_userManager = userManager;
_signInManager = signInManager;
_seedDataService = seedDataService;
_adminNotification = adminNotification;
_inApp = inApp;
_platformSettings = platformSettings;
_stripeService = stripeService;
_emailService = emailService;
_logger = logger;
}
/// <summary>
/// Renders the public registration page. Redirects already-authenticated users to the dashboard.
/// Reads platform settings at render time to gate whether registration is open (max-tenant cap)
/// and whether trials are enabled (controls which CTA the view shows). Re-populates the form
/// from <c>TempData["PendingRegistrationJson"]</c> if the user is returning after cancelling a
/// Stripe Checkout session so they don't have to retype their details.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
if (User.Identity?.IsAuthenticated == true)
return RedirectToAction("Index", "Dashboard");
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
await PopulateRegistrationViewBagAsync();
var defaultPlan = (ViewBag.PlanConfigs as List<SubscriptionPlanConfig>)
?.OrderBy(c => c.SortOrder).FirstOrDefault()?.Plan ?? 0;
// Re-populate form if returning from a cancelled payment
var prefillJson = TempData["PendingRegistrationJson"] as string;
var model = prefillJson != null
? JsonSerializer.Deserialize<RegisterCompanyDto>(prefillJson) ?? new RegisterCompanyDto { Plan = defaultPlan }
: new RegisterCompanyDto { Plan = defaultPlan };
return View(model);
}
/// <summary>
/// Processes the registration form submission. Validates the model, checks that registration is
/// still open (in case the tenant cap was hit after the page was loaded), and verifies the email
/// is not already in use. Then branches to <see cref="CreateWithTrialAsync"/> (trials enabled) or
/// <see cref="RedirectToStripeCheckoutAsync"/> (trials disabled / credit card required).
/// Duplicate email is checked here rather than relying on a DB unique constraint so we can show
/// a friendly field-level error message instead of a 500.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(RegisterCompanyDto model)
{
if (!ModelState.IsValid)
{
ViewBag.RegistrationOpen = await IsRegistrationOpenAsync();
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
if (!await IsRegistrationOpenAsync())
{
TempData["Error"] = "Registration is currently closed. Please contact us for more information.";
ViewBag.RegistrationOpen = false;
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
var existing = await _userManager.FindByEmailAsync(model.Email);
if (existing != null)
{
ModelState.AddModelError("Email", "An account with this email address already exists.");
ViewBag.RegistrationOpen = true;
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
var trialsEnabled = await IsTrialsEnabledAsync();
if (trialsEnabled)
{
return await CreateWithTrialAsync(model);
}
else
{
return await RedirectToStripeCheckoutAsync(model);
}
}
// ─── Trial path ───────────────────────────────────────────────────────────
/// <summary>
/// Creates the company and user immediately (no payment required). The trial length is read from
/// the <c>TrialPeriodDays</c> platform setting (default 7) so SuperAdmins can adjust it without
/// a deploy. A temporary password is generated and emailed; the user is flagged with
/// <c>MustChangePassword</c> claim so the first login redirects to the change-password flow.
/// Company and user creation are NOT in a DB transaction — if user creation fails, the company
/// row is manually deleted to avoid orphaned tenant records.
/// </summary>
private async Task<IActionResult> CreateWithTrialAsync(RegisterCompanyDto model)
{
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
var trialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
var companyCode = await GenerateUniqueCompanyCodeAsync(model.CompanyName);
var company = new Company
{
CompanyName = model.CompanyName,
CompanyCode = companyCode,
Phone = model.CompanyPhone,
PrimaryContactEmail = model.Email,
PrimaryContactName = $"{model.FirstName} {model.LastName}",
SubscriptionPlan = model.Plan,
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionStartDate = DateTime.UtcNow,
SubscriptionEndDate = DateTime.UtcNow.AddDays(trialPeriodDays),
IsAnnualBilling = model.IsAnnual,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
var tempPassword = GenerateTemporaryPassword();
var user = BuildUser(model.Email, model.FirstName, model.LastName, company.Id);
var createResult = await _userManager.CreateAsync(user, tempPassword);
if (!createResult.Succeeded)
{
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
foreach (var error in createResult.Errors)
ModelState.AddModelError(string.Empty, error.Description);
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
await FinalizeRegistrationAsync(user, company, model.Plan);
_ = SendWelcomeEmailAsync(model.Email, model.FirstName, tempPassword, model.Plan,
DateTime.UtcNow.AddDays(trialPeriodDays), $"{Request.Scheme}://{Request.Host}");
return RedirectToAction(nameof(Welcome));
}
// ─── Credit-card-required path ────────────────────────────────────────────
/// <summary>
/// Starts the paid-path registration by creating a Stripe Checkout session and redirecting the
/// user to Stripe's hosted payment page. A URL-safe GUID token is generated and embedded in both
/// the success and cancel return URLs. The <c>PendingRegistrationSession</c> is persisted to the
/// database AFTER Stripe confirms the session is valid — this prevents orphaned DB records when
/// Stripe rejects the request (e.g. invalid plan price ID). Account creation does not happen
/// here; it happens in <see cref="PaymentSuccess"/> after Stripe redirects back with the
/// Checkout session ID.
/// </summary>
private async Task<IActionResult> RedirectToStripeCheckoutAsync(RegisterCompanyDto model)
{
// Generate a token that travels in the URL — no session cookie needed to survive the Stripe redirect.
var token = Guid.NewGuid().ToString("N");
var successUrl = Url.Action(nameof(PaymentSuccess), "Registration", new { reg_token = token }, Request.Scheme)!;
var cancelUrl = Url.Action(nameof(PaymentCancelled), "Registration", new { reg_token = token }, Request.Scheme)!;
try
{
var checkoutUrl = await _stripeService.CreateRegistrationCheckoutSessionAsync(
model.Plan, model.IsAnnual, model.Email, model.CompanyName, successUrl, cancelUrl);
// Persist pending data AFTER confirming Stripe accepted the session.
_db.PendingRegistrationSessions.Add(new PendingRegistrationSession
{
Token = token,
CompanyName = model.CompanyName,
CompanyPhone = model.CompanyPhone,
FirstName = model.FirstName,
LastName = model.LastName,
Email = model.Email,
Plan = model.Plan,
IsAnnual = model.IsAnnual,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return Redirect(checkoutUrl);
}
catch (InvalidOperationException ex)
{
_logger.LogError(ex, "Stripe config error during registration checkout for plan {Plan}", model.Plan);
ModelState.AddModelError(string.Empty, ex.Message);
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
catch (Stripe.StripeException ex)
{
_logger.LogError(ex, "Stripe API error during registration checkout");
ModelState.AddModelError(string.Empty,
"A payment processor error occurred. Please try again or contact support.");
await PopulateRegistrationViewBagAsync();
return View("Index", model);
}
}
/// <summary>
/// Stripe return URL handler called after the customer completes payment in Stripe Checkout.
/// Looks up the <c>PendingRegistrationSession</c> by the opaque <c>reg_token</c> to recover the
/// registration data that could not be stored in a session cookie (which may not survive the
/// Stripe redirect on some browsers/devices). The Stripe session is re-validated before any
/// local company or user is created, and the pending session is only left marked completed once
/// registration succeeds. On recoverable failures the completion flag is released so the same
/// success URL can be retried instead of forcing manual support intervention.
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentSuccess(string? session_id, string? reg_token)
{
if (string.IsNullOrEmpty(session_id))
return RedirectToAction(nameof(Index));
if (string.IsNullOrEmpty(reg_token))
{
TempData["Error"] = "Your registration session has expired. Please fill in your details again.";
return RedirectToAction(nameof(Index));
}
var pendingSession = await _db.PendingRegistrationSessions
.FirstOrDefaultAsync(p => p.Token == reg_token);
if (pendingSession == null)
{
TempData["Error"] = "Your registration session was not found. Please fill in your details again.";
return RedirectToAction(nameof(Index));
}
var pending = new PendingRegistration(
pendingSession.CompanyName, pendingSession.CompanyPhone,
pendingSession.FirstName, pendingSession.LastName,
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
if (pendingSession.IsCompleted)
{
if (existingUser != null)
{
await SignInExistingRegistrationUserAsync(existingUser);
return RedirectToAction(nameof(Welcome));
}
TempData["Error"] = "Your registration was already submitted, but we couldn't finish signing you in. Please contact support with reference: " + session_id;
return RedirectToAction(nameof(Index));
}
if (!await _stripeService.IsRegistrationCheckoutPaidAsync(session_id))
{
TempData["Error"] = "We couldn't verify a completed payment for this registration session yet. Please try the link again in a moment, or contact support if the issue persists.";
return RedirectToAction(nameof(Index));
}
var keepSessionCompleted = false;
pendingSession.IsCompleted = true;
await _db.SaveChangesAsync();
try
{
// Guard against race condition: re-check capacity after Stripe redirect
if (!await IsRegistrationOpenAsync())
{
TempData["Error"] = "Registration is currently closed. Your payment has been received but no account was created. Please contact support.";
return RedirectToAction(nameof(Index));
}
// Recover gracefully if a prior attempt already created the user.
if (existingUser != null)
{
keepSessionCompleted = true;
await SignInExistingRegistrationUserAsync(existingUser);
return RedirectToAction(nameof(Welcome));
}
var companyCode = await GenerateUniqueCompanyCodeAsync(pending.CompanyName);
var company = new Company
{
CompanyName = pending.CompanyName,
CompanyCode = companyCode,
Phone = pending.CompanyPhone,
PrimaryContactEmail = pending.Email,
PrimaryContactName = $"{pending.FirstName} {pending.LastName}",
SubscriptionPlan = pending.Plan,
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionStartDate = DateTime.UtcNow,
SubscriptionEndDate = DateTime.UtcNow.AddDays(1), // Stripe fulfillment sets real date below
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
var tempPassword = GenerateTemporaryPassword();
var user = BuildUser(pending.Email, pending.FirstName, pending.LastName, company.Id);
var createResult = await _userManager.CreateAsync(user, tempPassword);
if (!createResult.Succeeded)
{
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogError("Failed to create user after payment for {Email}: {Errors}",
pending.Email, string.Join(", ", createResult.Errors.Select(e => e.Description)));
TempData["Error"] = "Your payment was received, but we couldn't finish creating your account. Please try the success link again, or contact support with reference: " + session_id;
return RedirectToAction(nameof(Index));
}
// Link the Stripe subscription (sets real SubscriptionEndDate)
try
{
await _stripeService.FulfillRegistrationCheckoutAsync(session_id, company.Id, pending.Plan);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fulfill registration checkout {SessionId} for company {CompanyId}",
session_id, company.Id);
// Non-fatal — subscription dates can be synced manually later
}
await _userManager.AddClaimAsync(user, new Claim("MustChangePassword", "true"));
await FinalizeRegistrationAsync(user, company, pending.Plan);
_ = SendWelcomeEmailAsync(pending.Email, pending.FirstName, tempPassword, pending.Plan,
null, $"{Request.Scheme}://{Request.Host}");
keepSessionCompleted = true;
return RedirectToAction(nameof(Welcome));
}
finally
{
if (!keepSessionCompleted)
{
pendingSession.IsCompleted = false;
await _db.SaveChangesAsync();
}
}
}
/// <summary>
/// Stripe cancel URL handler called when the user clicks "Back" or otherwise abandons the Stripe
/// Checkout page. Recovers the pending registration data from the DB and serialises it into
/// <c>TempData</c> so the registration form is pre-populated when <see cref="Index"/> renders,
/// then removes the <c>PendingRegistrationSession</c> record — a new one is created if the user
/// tries again. No account is created at this point; the user is shown a friendly error message.
/// </summary>
[HttpGet]
public async Task<IActionResult> PaymentCancelled(string? reg_token)
{
if (!string.IsNullOrEmpty(reg_token))
{
var pendingSession = await _db.PendingRegistrationSessions
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
if (pendingSession != null)
{
// Pre-populate the form so the user doesn't have to retype everything
TempData["PendingRegistrationJson"] = JsonSerializer.Serialize(new RegisterCompanyDto
{
CompanyName = pendingSession.CompanyName,
CompanyPhone = pendingSession.CompanyPhone,
FirstName = pendingSession.FirstName,
LastName = pendingSession.LastName,
Email = pendingSession.Email,
Plan = pendingSession.Plan,
IsAnnual = pendingSession.IsAnnual
});
_db.PendingRegistrationSessions.Remove(pendingSession);
await _db.SaveChangesAsync();
}
}
TempData["Error"] = "Payment was cancelled — your account has not been created. Please try again whenever you're ready.";
return RedirectToAction(nameof(Index));
}
// ─── Shared post-creation steps ───────────────────────────────────────────
/// <summary>
/// Shared post-creation step executed for both the trial and paid paths. Assigns the
/// Administrator role, seeds company lookup tables, records Terms-of-Service acceptance, sends
/// admin notifications, and finally signs the user in.
/// Sign-in is intentionally the LAST step — once <c>SignInAsync</c> runs the HTTP context user
/// becomes the new company admin, which changes the tenant context for any subsequent
/// <c>_db</c> writes. Notifications are therefore awaited synchronously (not fire-and-forget)
/// before sign-in to prevent two specific bugs: (1) in-app notifications written for SuperAdmins
/// would have their <c>CompanyId=0</c> sentinel overwritten by the tenant context after sign-in;
/// (2) <c>IPlatformSettingsService</c> reads use a scoped <c>DbContext</c> that can be disposed
/// if the caller fire-and-forgets.
/// </summary>
private async Task FinalizeRegistrationAsync(ApplicationUser user, Company company, int plan)
{
await _userManager.AddToRoleAsync(user, "Administrator");
try { await _seedDataService.SeedCompanyLookupsAsync(company.Id); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to seed lookups for company {CompanyId}", company.Id);
}
// Record ToS acceptance while still anonymous — CompanyId is set explicitly on the entity
// so UpdateTimestampsAndTenancy won't overwrite it.
_db.TermsAcceptances.Add(new TermsAcceptance
{
UserId = user.Id,
CompanyId = company.Id,
TosVersion = AppConstants.Legal.CurrentTosVersion,
AcceptedAt = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = Request.Headers["User-Agent"].FirstOrDefault()
});
await _db.SaveChangesAsync();
_logger.LogInformation("New company registered: {CompanyName} (ID {CompanyId}) by {Email}",
company.CompanyName, company.Id, user.Email);
// Await both notifications BEFORE SignInAsync for two reasons:
// 1. InAppNotification: CreateForSuperAdminsAsync writes CompanyId=0 (SuperAdmin sentinel).
// After SignIn, UpdateTimestampsAndTenancy would overwrite that 0 with the new company's ID,
// routing the bell notification to the wrong recipient.
// 2. AdminEmail: NotifyNewCompanyRegisteredAsync uses IPlatformSettingsService (→ DbContext).
// Fire-and-forget tasks can outlive the scoped DbContext, causing silent failures where
// GetAdminEmailsAsync() throws ObjectDisposedException and no email is sent at all.
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == plan && c.IsActive, ignoreQueryFilters: true);
var planName = planConfig?.DisplayName ?? plan.ToString();
await _adminNotification.NotifyNewCompanyRegisteredAsync(
company.Id, company.CompanyName, planName,
$"{user.FirstName} {user.LastName}", user.Email!);
await _inApp.CreateForSuperAdminsAsync(
"New Company Registered",
$"{company.CompanyName} signed up on the {planName} plan.",
"NewCompany",
$"/Companies/Details/{company.Id}");
// Sign in last — after this point the HTTP context user becomes the new company admin,
// which would change the tenant context for any subsequent _db writes.
user.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await _signInManager.SignInAsync(user, isPersistent: false);
}
/// <summary>
/// Signs in an already-created registration user on idempotent success-link retries.
/// This is intentionally narrower than <see cref="FinalizeRegistrationAsync"/> because all
/// registration side effects should already have happened on the original successful run.
/// </summary>
private async Task SignInExistingRegistrationUserAsync(ApplicationUser user)
{
user.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await _signInManager.SignInAsync(user, isPersistent: false);
}
/// <summary>
/// Renders the post-registration welcome page. Requires authentication (the user was just signed
/// in by <see cref="FinalizeRegistrationAsync"/>). Determines whether the account is on a free
/// trial by checking for the absence of a <c>StripeSubscriptionId</c> — trial accounts are
/// created without a subscription ID, which the welcome view uses to display an upgrade prompt
/// or hide the trial countdown pill.
/// </summary>
[HttpGet]
[Authorize]
public async Task<IActionResult> Welcome()
{
ViewData["Title"] = "Welcome to Powder Coating Logix";
var user = await _userManager.GetUserAsync(User);
ViewBag.FirstName = user?.FirstName ?? User.Identity?.Name?.Split('@')[0] ?? "there";
// Determine if this is a trial or a paid subscription so the view can hide the trial pill accordingly
if (user != null && user.CompanyId > 0)
{
var company = await _unitOfWork.Companies.GetByIdAsync(user.CompanyId, ignoreQueryFilters: true);
var isOnTrial = company != null && string.IsNullOrEmpty(company.StripeSubscriptionId);
ViewBag.IsOnTrial = isOnTrial;
ViewBag.TrialEndDate = company?.SubscriptionEndDate;
}
else
{
ViewBag.IsOnTrial = false;
ViewBag.TrialEndDate = null;
}
return View();
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/// <summary>
/// Returns whether the free-trial sign-up path is active. Reads the <c>TrialsEnabled</c>
/// platform setting; a missing or null value is treated as enabled (safe default so a freshly
/// deployed instance with no platform settings can still onboard users).
/// </summary>
private async Task<bool> IsTrialsEnabledAsync()
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.TrialsEnabled);
// Null/missing = treat as enabled (safe default)
return raw == null || !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks whether the platform has capacity for a new tenant. Reads the <c>MaxTenants</c>
/// platform setting; a blank value or 0 means unlimited. Uses <c>IgnoreQueryFilters</c> on the
/// company count so soft-deleted companies still count against the cap (preventing circumvention
/// by deleting and re-registering). Marked <c>internal</c> to allow unit-testing without
/// mocking the full HTTP pipeline.
/// </summary>
internal async Task<bool> IsRegistrationOpenAsync()
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.MaxTenants);
// Blank or 0 = unlimited
if (!int.TryParse(raw, out var max) || max <= 0)
return true;
var count = await _unitOfWork.Companies.CountAsync(ignoreQueryFilters: true);
return count < max;
}
/// <summary>
/// Populates shared ViewBag properties required by the registration view: active subscription
/// plan configs (sorted by <c>SortOrder</c>), the trials-enabled flag, and the trial period
/// length in days. Called by both the GET and failed POST paths so the view always has the data
/// it needs without the caller repeating these lookups.
/// </summary>
private async Task PopulateRegistrationViewBagAsync()
{
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
ViewBag.PlanConfigs = planConfigs;
ViewBag.TrialsEnabled = await IsTrialsEnabledAsync();
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
ViewBag.TrialPeriodDays = int.TryParse(rawTrialDays, out var td) ? td : 7;
}
/// <summary>
/// Constructs the <c>ApplicationUser</c> for a new company administrator. All permissions are
/// granted by default because the first user of a new company must be able to do everything.
/// <c>EmailConfirmed</c> is set to <c>true</c> immediately — we skip the email-verification
/// step for registrations because the welcome email with a temp password serves as implicit
/// confirmation that the address is reachable.
/// </summary>
private static ApplicationUser BuildUser(
string email, string firstName, string lastName, int companyId)
{
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = firstName,
LastName = lastName,
CompanyId = companyId,
CompanyRole = "CompanyAdmin",
HireDate = DateTime.UtcNow.Date,
IsActive = true,
EmailConfirmed = true,
CreatedAt = DateTime.UtcNow
};
user.GrantAllPermissions();
return user;
}
/// <summary>
/// Derives a short, unique company code from the company name (e.g. "Acme Powder Coating" →
/// "APC"). Stop words (the, and, inc, llc, etc.) are stripped first. One-, two-, or three-word
/// names each use a different initialism strategy so the code is always 3 characters. A numeric
/// suffix (2, 3, …) is appended if the derived code is already taken. The collision check loads
/// all existing codes into memory — acceptable for small tenant counts but should be replaced
/// with a DB-level unique check if the platform ever scales to thousands of tenants.
/// </summary>
private async Task<string> GenerateUniqueCompanyCodeAsync(string companyName)
{
var stopWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"the", "and", "of", "a", "an", "for", "inc", "llc", "ltd", "co", "corp"
};
var words = companyName
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => !stopWords.Contains(w))
.ToList();
string baseCode;
if (words.Count >= 3)
baseCode = new string(words.Take(3).Select(w => char.ToUpper(w[0])).ToArray());
else if (words.Count == 2)
baseCode = char.ToUpper(words[0][0]).ToString() + char.ToUpper(words[1][0]).ToString() +
char.ToUpper(words[1].Length > 1 ? words[1][1] : words[0][1]);
else if (words.Count == 1)
baseCode = words[0].Length >= 4
? words[0][..4].ToUpper()
: words[0].ToUpper().PadRight(3, 'X');
else
baseCode = "CO";
var code = baseCode;
var allCodes = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
.Select(c => c.CompanyCode)
.Where(c => c != null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
int suffix = 2;
while (allCodes.Contains(code))
{
code = baseCode + suffix;
suffix++;
}
return code;
}
// ─── Session model ────────────────────────────────────────────────────────
private sealed record PendingRegistration(
string CompanyName,
string? CompanyPhone,
string FirstName,
string LastName,
string Email,
int Plan,
bool IsAnnual = false);
// ─── Temporary password generation ────────────────────────────────────────
/// <summary>
/// Generates a cryptographically random 12-character temporary password that satisfies ASP.NET
/// Core Identity's default complexity rules (uppercase, lowercase, digit, special character).
/// Ambiguous characters (I, O, 0, 1, l) are excluded from the alphabet to reduce transcription
/// errors in case the user reads the password from the email rather than copying it.
/// A Fisher-Yates shuffle with a second batch of random bytes ensures no character class is
/// predictably in a fixed position (e.g. the guaranteed uppercase is not always at index 0).
/// </summary>
private static string GenerateTemporaryPassword()
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
const string lower = "abcdefghjkmnpqrstuvwxyz"; // no i, l, o
const string digits = "23456789"; // no 0, 1
const string special = "!@#$%&*";
const string all = upper + lower + digits + special;
var bytes = new byte[16];
RandomNumberGenerator.Fill(bytes);
var pw = new char[12];
// Guarantee one character of each required class
pw[0] = upper [bytes[0] % upper.Length];
pw[1] = lower [bytes[1] % lower.Length];
pw[2] = digits [bytes[2] % digits.Length];
pw[3] = special[bytes[3] % special.Length];
for (int i = 4; i < 12; i++)
pw[i] = all[bytes[i] % all.Length];
// Fisher-Yates shuffle with fresh random bytes
RandomNumberGenerator.Fill(bytes);
for (int i = 11; i > 0; i--)
{
int j = bytes[i] % (i + 1);
(pw[i], pw[j]) = (pw[j], pw[i]);
}
return new string(pw);
}
// ─── Welcome email ────────────────────────────────────────────────────────
/// <summary>
/// Sends the welcome email with temporary password, plan-specific feature highlights, and an
/// optional trial-expiry blurb. Called fire-and-forget so a transient email failure never blocks
/// the registration response. Logged at Error because a missing welcome email means the user
/// cannot retrieve their temporary password and must contact support.
/// </summary>
private async Task SendWelcomeEmailAsync(
string email, string firstName, string tempPassword,
int plan, DateTime? trialEndDate, string baseUrl)
{
try
{
var planName = plan switch
{
3 => "Starter",
0 => "Basic",
1 => "Pro",
2 => "Enterprise",
_ => "Starter"
};
// Plan-specific feature bullets (plain + html pairs)
var (plainFeatures, htmlFeatures) = plan switch
{
// Starter
3 => (
"• Job & quote management for up to 1 active user\r\n" +
"• Customer records and job history\r\n" +
"• Inventory tracking and low-stock alerts\r\n" +
"• PDF quotes and invoices\r\n" +
"• Basic financial reports",
"<li>Job &amp; quote management for up to 1 active user</li>" +
"<li>Customer records and job history</li>" +
"<li>Inventory tracking and low-stock alerts</li>" +
"<li>PDF quotes and invoices</li>" +
"<li>Basic financial reports</li>"
),
// Basic
0 => (
"• Job & quote management for up to 3 users\r\n" +
"• Customer records and job history\r\n" +
"• Inventory tracking, purchase orders, and vendor management\r\n" +
"• PDF quotes, invoices, and deposits\r\n" +
"• Oven scheduler for batch planning\r\n" +
"• Equipment and maintenance tracking\r\n" +
"• Financial reports and AR aging",
"<li>Job &amp; quote management for up to 3 users</li>" +
"<li>Customer records and job history</li>" +
"<li>Inventory tracking, purchase orders, and vendor management</li>" +
"<li>PDF quotes, invoices, and deposits</li>" +
"<li>Oven scheduler for batch planning</li>" +
"<li>Equipment and maintenance tracking</li>" +
"<li>Financial reports and AR aging</li>"
),
// Pro
1 => (
"• Everything in Basic, plus up to 10 users\r\n" +
"• AI Photo Quoting — upload a photo, get an instant estimate\r\n" +
"• AI Accounting Assistant — receipt scanning, cash flow forecasting, anomaly detection\r\n" +
"• Online customer approval portal with Stripe payments\r\n" +
"• Advanced analytics and custom reports\r\n" +
"• Priority support",
"<li>Everything in Basic, plus up to 10 users</li>" +
"<li><strong>AI Photo Quoting</strong> — upload a photo, get an instant estimate</li>" +
"<li><strong>AI Accounting Assistant</strong> — receipt scanning, cash flow forecasting, anomaly detection</li>" +
"<li>Online customer approval portal with Stripe payments</li>" +
"<li>Advanced analytics and custom reports</li>" +
"<li>Priority support</li>"
),
// Enterprise
_ => (
"• Everything in Pro, with unlimited users and jobs\r\n" +
"• AI Photo Quoting and AI Accounting Assistant\r\n" +
"• Online customer approval portal with Stripe payments\r\n" +
"• Full analytics suite and all report exports\r\n" +
"• Dedicated onboarding and priority support\r\n" +
"• Custom integrations available on request",
"<li>Everything in Pro, with unlimited users and jobs</li>" +
"<li><strong>AI Photo Quoting</strong> and <strong>AI Accounting Assistant</strong></li>" +
"<li>Online customer approval portal with Stripe payments</li>" +
"<li>Full analytics suite and all report exports</li>" +
"<li>Dedicated onboarding and priority support</li>" +
"<li>Custom integrations available on request</li>"
)
};
var trialPlainBlurb = trialEndDate.HasValue
? $"Your {planName} trial is active through {trialEndDate.Value:MMMM d, yyyy}. No credit card is required during the trial — we'll remind you before it ends.\r\n\r\n"
: string.Empty;
var trialHtmlBlurb = trialEndDate.HasValue
? $"<p style='background:#fff8e1;border-left:4px solid #f59e0b;padding:10px 14px;margin:20px 0;'>" +
$"Your <strong>{planName} trial</strong> is active through <strong>{trialEndDate.Value:MMMM d, yyyy}</strong>. " +
$"No credit card is required during the trial — we'll remind you before it ends.</p>"
: string.Empty;
var subject = $"Welcome to Powder Coating Logix — Your {planName} Account Is Ready";
var plain =
$"Hi {firstName},\r\n\r\n" +
$"Your Powder Coating Logix {planName} account is ready. Here's your temporary password to log in:\r\n\r\n" +
$" Temporary password: {tempPassword}\r\n\r\n" +
$"You'll be prompted to set a new password the first time you log in.\r\n\r\n" +
trialPlainBlurb +
$"Your {planName} plan includes:\r\n" +
plainFeatures + "\r\n\r\n" +
$"Get started quickly:\r\n" +
$" 1. Log in at {baseUrl}\r\n" +
$" 2. Complete the Setup Wizard — it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.\r\n\r\n" +
$"Need help? Our AI assistant is built right into the app — just click the chat icon. You can also reach us at support@powdercoatinglogix.com.\r\n\r\n" +
$"Welcome aboard,\r\n" +
$"The Powder Coating Logix Team";
var html =
$@"<!DOCTYPE html>
<html>
<head><meta charset='utf-8'></head>
<body style='margin:0;padding:0;background:#f4f4f4;font-family:Arial,sans-serif;'>
<table width='100%' cellpadding='0' cellspacing='0' style='background:#f4f4f4;padding:30px 0;'>
<tr><td align='center'>
<table width='600' cellpadding='0' cellspacing='0' style='background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);'>
<!-- Header -->
<tr>
<td style='background:#1a3a5c;padding:28px 40px;'>
<p style='margin:0;color:#ffffff;font-size:22px;font-weight:bold;'>Powder Coating Logix</p>
<p style='margin:6px 0 0;color:#a8c4e0;font-size:13px;'>Shop management software built for powder coaters</p>
</td>
</tr>
<!-- Body -->
<tr><td style='padding:36px 40px;color:#333333;font-size:15px;line-height:1.6;'>
<p style='margin:0 0 16px;'>Hi {firstName},</p>
<p style='margin:0 0 24px;'>Your <strong>Powder Coating Logix {planName}</strong> account is ready. Here's your temporary password to get started:</p>
<div style='background:#f0f4f8;border:1px solid #d0dce8;border-radius:6px;padding:16px 24px;margin:0 0 24px;text-align:center;'>
<p style='margin:0 0 4px;font-size:12px;color:#666;text-transform:uppercase;letter-spacing:0.05em;'>Temporary Password</p>
<p style='margin:0;font-family:monospace;font-size:22px;font-weight:bold;letter-spacing:0.1em;color:#1a3a5c;'>{tempPassword}</p>
</div>
<p style='margin:0 0 24px;color:#555;font-size:14px;'>You'll be prompted to set a new password the first time you log in.</p>
{trialHtmlBlurb}
<p style='margin:0 0 10px;font-weight:bold;'>Your {planName} plan includes:</p>
<ul style='margin:0 0 24px;padding-left:20px;color:#444;'>
{htmlFeatures}
</ul>
<p style='margin:0 0 10px;font-weight:bold;'>Get started quickly:</p>
<ol style='margin:0 0 24px;padding-left:20px;color:#444;'>
<li style='margin-bottom:8px;'><a href='{baseUrl}' style='color:#1a3a5c;'>Log in to your account</a></li>
<li>Complete the <strong>Setup Wizard</strong> — it takes about 5 minutes and configures your rates, inventory, and shop profile so quotes price correctly from day one.</li>
</ol>
<p style='margin:0 0 24px;'>Need help? Our <strong>AI assistant</strong> is built right into the app — just click the chat icon. You can also reach us any time at <a href='mailto:support@powdercoatinglogix.com' style='color:#1a3a5c;'>support@powdercoatinglogix.com</a>.</p>
<p style='margin:0;'>Welcome aboard,<br><strong>The Powder Coating Logix Team</strong></p>
</td></tr>
<!-- Footer -->
<tr>
<td style='background:#f8f9fa;border-top:1px solid #e9ecef;padding:20px 40px;text-align:center;'>
<p style='margin:0;font-size:12px;color:#888;'>
&copy; {DateTime.UtcNow.Year} Powder Coating Logix &nbsp;&bull;&nbsp;
<a href='mailto:support@powdercoatinglogix.com' style='color:#888;'>support@powdercoatinglogix.com</a>
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>";
await _emailService.SendEmailAsync(email, firstName, subject, plain, html);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send welcome email to {Email}", email);
}
}
}