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; /// /// 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 /// PendingRegistrationSession 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. /// [AllowAnonymous] [EnableRateLimiting(AppConstants.RateLimitPolicies.Registration)] public class RegistrationController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ApplicationDbContext _db; private readonly UserManager _userManager; private readonly SignInManager _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 _logger; public RegistrationController( IUnitOfWork unitOfWork, ApplicationDbContext db, UserManager userManager, SignInManager signInManager, ISeedDataService seedDataService, IAdminNotificationService adminNotification, IInAppNotificationService inApp, IPlatformSettingsService platformSettings, IStripeService stripeService, IEmailService emailService, ILogger logger) { _unitOfWork = unitOfWork; _db = db; _userManager = userManager; _signInManager = signInManager; _seedDataService = seedDataService; _adminNotification = adminNotification; _inApp = inApp; _platformSettings = platformSettings; _stripeService = stripeService; _emailService = emailService; _logger = logger; } /// /// 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 TempData["PendingRegistrationJson"] if the user is returning after cancelling a /// Stripe Checkout session so they don't have to retype their details. /// [HttpGet] public async Task Index() { if (User.Identity?.IsAuthenticated == true) return RedirectToAction("Index", "Dashboard"); ViewBag.RegistrationOpen = await IsRegistrationOpenAsync(); await PopulateRegistrationViewBagAsync(); var defaultPlan = (ViewBag.PlanConfigs as List) ?.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(prefillJson) ?? new RegisterCompanyDto { Plan = defaultPlan } : new RegisterCompanyDto { Plan = defaultPlan }; return View(model); } /// /// 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 (trials enabled) or /// (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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 ─────────────────────────────────────────────────────────── /// /// Creates the company and user immediately (no payment required). The trial length is read from /// the TrialPeriodDays platform setting (default 7) so SuperAdmins can adjust it without /// a deploy. A temporary password is generated and emailed; the user is flagged with /// MustChangePassword 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. /// private async Task 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 ──────────────────────────────────────────── /// /// 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 PendingRegistrationSession 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 after Stripe redirects back with the /// Checkout session ID. /// private async Task 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); } } /// /// Stripe return URL handler called after the customer completes payment in Stripe Checkout. /// Looks up the PendingRegistrationSession by the opaque reg_token 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. /// [HttpGet] public async Task 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(); } } } /// /// 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 /// TempData so the registration form is pre-populated when renders, /// then removes the PendingRegistrationSession 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. /// [HttpGet] public async Task 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 ─────────────────────────────────────────── /// /// 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 SignInAsync runs the HTTP context user /// becomes the new company admin, which changes the tenant context for any subsequent /// _db 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 CompanyId=0 sentinel overwritten by the tenant context after sign-in; /// (2) IPlatformSettingsService reads use a scoped DbContext that can be disposed /// if the caller fire-and-forgets. /// 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); } /// /// Signs in an already-created registration user on idempotent success-link retries. /// This is intentionally narrower than because all /// registration side effects should already have happened on the original successful run. /// private async Task SignInExistingRegistrationUserAsync(ApplicationUser user) { user.LastLoginDate = DateTime.UtcNow; await _userManager.UpdateAsync(user); await _signInManager.SignInAsync(user, isPersistent: false); } /// /// Renders the post-registration welcome page. Requires authentication (the user was just signed /// in by ). Determines whether the account is on a free /// trial by checking for the absence of a StripeSubscriptionId — trial accounts are /// created without a subscription ID, which the welcome view uses to display an upgrade prompt /// or hide the trial countdown pill. /// [HttpGet] [Authorize] public async Task 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 ────────────────────────────────────────────────────────────── /// /// Returns whether the free-trial sign-up path is active. Reads the TrialsEnabled /// 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). /// private async Task IsTrialsEnabledAsync() { var raw = await _platformSettings.GetAsync(PlatformSettingKeys.TrialsEnabled); // Null/missing = treat as enabled (safe default) return raw == null || !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase); } /// /// Checks whether the platform has capacity for a new tenant. Reads the MaxTenants /// platform setting; a blank value or 0 means unlimited. Uses IgnoreQueryFilters on the /// company count so soft-deleted companies still count against the cap (preventing circumvention /// by deleting and re-registering). Marked internal to allow unit-testing without /// mocking the full HTTP pipeline. /// internal async Task 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; } /// /// Populates shared ViewBag properties required by the registration view: active subscription /// plan configs (sorted by SortOrder), 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. /// 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; } /// /// Constructs the ApplicationUser 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. /// EmailConfirmed is set to true 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. /// 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; } /// /// 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. /// private async Task GenerateUniqueCompanyCodeAsync(string companyName) { var stopWords = new HashSet(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 ──────────────────────────────────────── /// /// 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). /// 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 ──────────────────────────────────────────────────────── /// /// 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. /// 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", "
  • Job & quote management for up to 1 active user
  • " + "
  • Customer records and job history
  • " + "
  • Inventory tracking and low-stock alerts
  • " + "
  • PDF quotes and invoices
  • " + "
  • Basic financial reports
  • " ), // 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", "
  • Job & quote management for up to 3 users
  • " + "
  • Customer records and job history
  • " + "
  • Inventory tracking, purchase orders, and vendor management
  • " + "
  • PDF quotes, invoices, and deposits
  • " + "
  • Oven scheduler for batch planning
  • " + "
  • Equipment and maintenance tracking
  • " + "
  • Financial reports and AR aging
  • " ), // 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", "
  • Everything in Basic, plus up to 10 users
  • " + "
  • AI Photo Quoting — upload a photo, get an instant estimate
  • " + "
  • AI Accounting Assistant — receipt scanning, cash flow forecasting, anomaly detection
  • " + "
  • Online customer approval portal with Stripe payments
  • " + "
  • Advanced analytics and custom reports
  • " + "
  • Priority support
  • " ), // 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", "
  • Everything in Pro, with unlimited users and jobs
  • " + "
  • AI Photo Quoting and AI Accounting Assistant
  • " + "
  • Online customer approval portal with Stripe payments
  • " + "
  • Full analytics suite and all report exports
  • " + "
  • Dedicated onboarding and priority support
  • " + "
  • Custom integrations available on request
  • " ) }; 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 ? $"

    " + $"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.

    " : 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 = $@"

    Powder Coating Logix

    Shop management software built for powder coaters

    Hi {firstName},

    Your Powder Coating Logix {planName} account is ready. Here's your temporary password to get started:

    Temporary Password

    {tempPassword}

    You'll be prompted to set a new password the first time you log in.

    {trialHtmlBlurb}

    Your {planName} plan includes:

      {htmlFeatures}

    Get started quickly:

    1. Log in to your account
    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.

    Need help? Our AI assistant is built right into the app — just click the chat icon. You can also reach us any time at support@powdercoatinglogix.com.

    Welcome aboard,
    The Powder Coating Logix Team

    © {DateTime.UtcNow.Year} Powder Coating Logix  •  support@powdercoatinglogix.com

    "; await _emailService.SendEmailAsync(email, firstName, subject, plain, html); } catch (Exception ex) { _logger.LogError(ex, "Failed to send welcome email to {Email}", email); } } }