Harden paid registration flow and add unit tests
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user