Harden paid registration flow and add unit tests

This commit is contained in:
2026-04-24 21:10:28 -04:00
parent 4153acf3aa
commit 27ac793f62
8 changed files with 817 additions and 79 deletions
@@ -262,11 +262,10 @@ public class RegistrationController : Controller
/// 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). Marks the session as completed before creating the
/// account to prevent duplicate submissions if the user hits reload. Contains a second open-cap
/// check in case the tenant limit was reached between the user starting and completing checkout.
/// Calls <c>FulfillRegistrationCheckoutAsync</c> to link the Stripe subscription (sets the real
/// <c>SubscriptionEndDate</c>); failure is non-fatal — dates can be corrected manually.
/// 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)
@@ -281,99 +280,123 @@ public class RegistrationController : Controller
}
var pendingSession = await _db.PendingRegistrationSessions
.FirstOrDefaultAsync(p => p.Token == reg_token && !p.IsCompleted);
.FirstOrDefaultAsync(p => p.Token == reg_token);
if (pendingSession == null)
{
TempData["Error"] = "Your registration session was not found or has already been completed. Please fill in your details again.";
TempData["Error"] = "Your registration session was not found. Please fill in your details again.";
return RedirectToAction(nameof(Index));
}
// Map DB record to the internal record used below
var pending = new PendingRegistration(
pendingSession.CompanyName, pendingSession.CompanyPhone,
pendingSession.FirstName, pendingSession.LastName,
pendingSession.Email, pendingSession.Plan, pendingSession.IsAnnual);
// Mark completed to prevent duplicate submissions
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();
// 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));
}
// Guard against race condition (duplicate submission)
if (await _userManager.FindByEmailAsync(pending.Email) != null)
{
// Account already exists — just sign them in and go to dashboard
var existingUser = await _userManager.FindByEmailAsync(pending.Email);
if (existingUser != null)
{
existingUser.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(existingUser);
await _signInManager.SignInAsync(existingUser, isPersistent: false);
}
return RedirectToAction("Index", "Dashboard");
}
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 encountered an error creating your account. " +
"Please 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);
// 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));
}
catch (Exception ex)
finally
{
_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
if (!keepSessionCompleted)
{
pendingSession.IsCompleted = false;
await _db.SaveChangesAsync();
}
}
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}");
return RedirectToAction(nameof(Welcome));
}
/// <summary>
@@ -480,6 +503,18 @@ public class RegistrationController : Controller
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