feat: Add Stripe payment integration and subscription management

- Add Stripe.net SDK for payment processing
- Implement StripeService with checkout sessions, customer portal, webhooks
- Implement SubscriptionService for quota management
- Add quota enforcement to CVCheckService
- Create Pricing, Billing, Settings pages
- Add checkout success/cancel pages
- Update Check and Dashboard with usage indicators
- Add ResetMonthlyUsageJob for billing cycle resets
- Add database migration for subscription fields

Plan tiers: Free (3 checks), Professional £49/mo (30), Enterprise £199/mo (unlimited)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 12:03:24 +00:00
parent 6f384f8d09
commit 28d7d41b25
27 changed files with 2427 additions and 1 deletions

View File

@@ -0,0 +1,220 @@
@page "/account/billing"
@attribute [Authorize]
@rendermode InteractiveServer
@inject ISubscriptionService SubscriptionService
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager
<PageTitle>Billing - TrueCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<h1 class="fw-bold mb-1">Billing & Subscription</h1>
<p class="text-muted">Manage your subscription and view usage</p>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
</div>
}
@if (_isLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
else if (_subscription != null)
{
<!-- Current Plan Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h5 class="fw-bold mb-1">Current Plan</h5>
<p class="text-muted small mb-0">Your active subscription details</p>
</div>
<span class="badge bg-primary-subtle text-primary px-3 py-2 fs-6">
@_subscription.Plan
</span>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="p-3 rounded" style="background: var(--truecv-bg-muted);">
<div class="small text-muted mb-1">Price</div>
<div class="fw-bold fs-4">@_subscription.DisplayPrice</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded" style="background: var(--truecv-bg-muted);">
<div class="small text-muted mb-1">Status</div>
<div class="fw-bold fs-4">
@if (_subscription.HasActiveSubscription)
{
<span class="text-success">Active</span>
}
else if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free)
{
<span class="text-muted">Free Tier</span>
}
else
{
<span class="text-warning">@(_subscription.SubscriptionStatus ?? "Inactive")</span>
}
</div>
</div>
</div>
</div>
@if (_subscription.CurrentPeriodEnd.HasValue)
{
<div class="mt-3 small text-muted">
Next billing date: <strong>@_subscription.CurrentPeriodEnd.Value.ToString("dd MMMM yyyy")</strong>
</div>
}
<div class="d-flex gap-2 mt-4">
<a href="/pricing" class="btn btn-primary">
@if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free)
{
<span>Upgrade Plan</span>
}
else
{
<span>Change Plan</span>
}
</a>
@if (_subscription.HasActiveSubscription)
{
<form action="/api/billing/portal" method="post">
<button type="submit" class="btn btn-outline-secondary">
Manage Subscription
</button>
</form>
}
</div>
</div>
</div>
<!-- Usage Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Usage This Month</h5>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted">CV Checks</span>
<span class="fw-semibold">
@if (_subscription.IsUnlimited)
{
<span>@_subscription.ChecksUsedThisMonth used (Unlimited)</span>
}
else
{
<span>@_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit</span>
}
</span>
</div>
@if (!_subscription.IsUnlimited)
{
var percentage = _subscription.MonthlyLimit > 0
? Math.Min(100, (_subscription.ChecksUsedThisMonth * 100) / _subscription.MonthlyLimit)
: 0;
var progressClass = percentage >= 90 ? "bg-danger" : percentage >= 75 ? "bg-warning" : "bg-primary";
<div class="progress" style="height: 10px;">
<div class="progress-bar @progressClass" role="progressbar" style="width: @percentage%"></div>
</div>
@if (_subscription.ChecksRemaining <= 0)
{
<div class="alert alert-warning mt-3 mb-0 py-2">
<small>
You've used all your checks this month.
<a href="/pricing" class="alert-link">Upgrade your plan</a> for more.
</small>
</div>
}
else if (_subscription.ChecksRemaining <= 3 && _subscription.Plan != TrueCV.Domain.Enums.UserPlan.Free)
{
<div class="alert alert-info mt-3 mb-0 py-2">
<small>
You have @_subscription.ChecksRemaining checks remaining this month.
</small>
</div>
}
}
</div>
</div>
</div>
<!-- Manage Billing Card (for paid users) -->
@if (_subscription.HasActiveSubscription)
{
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Billing Management</h5>
<p class="text-muted mb-4">
Use the Stripe Customer Portal to update your payment method, view invoices, or cancel your subscription.
</p>
<form action="/api/billing/portal" method="post">
<button type="submit" class="btn btn-outline-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4zm2-1a1 1 0 0 0-1 1v1h14V4a1 1 0 0 0-1-1H2zm13 4H1v5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7z"/>
<path d="M2 10a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-1z"/>
</svg>
Open Billing Portal
</button>
</form>
</div>
</div>
}
}
</div>
</div>
</div>
@code {
private SubscriptionInfoDto? _subscription;
private bool _isLoading = true;
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("error", out var error))
{
_errorMessage = error == "portal_failed"
? "Unable to open billing portal. Please try again."
: error.ToString();
}
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
}
}
catch (Exception)
{
_errorMessage = "Unable to load subscription information.";
}
finally
{
_isLoading = false;
}
}
}

View File

@@ -0,0 +1,235 @@
@page "/account/settings"
@attribute [Authorize]
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Identity
@using TrueCV.Infrastructure.Identity
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager
@inject ILogger<Settings> Logger
<PageTitle>Account Settings - TrueCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-4">
<h1 class="fw-bold mb-1">Account Settings</h1>
<p class="text-muted">Manage your account details and security</p>
</div>
@if (!string.IsNullOrEmpty(_successMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@_successMessage
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
</div>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
</div>
}
<!-- Profile Section -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Profile Information</h5>
<div class="mb-3">
<label class="form-label small text-muted">Email Address</label>
<input type="email" class="form-control" value="@_userEmail" disabled />
<small class="text-muted">Email cannot be changed</small>
</div>
<div class="mb-3">
<label class="form-label small text-muted">Current Plan</label>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary-subtle text-primary px-3 py-2">@_userPlan</span>
<a href="/account/billing" class="btn btn-sm btn-link">Manage</a>
</div>
</div>
<div class="mb-0">
<label class="form-label small text-muted">Member Since</label>
<p class="mb-0">@_memberSince?.ToString("dd MMMM yyyy")</p>
</div>
</div>
</div>
<!-- Change Password Section -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Change Password</h5>
<EditForm Model="_passwordModel" OnValidSubmit="ChangePassword" FormName="change-password">
<DataAnnotationsValidator />
<div class="mb-3">
<label for="currentPassword" class="form-label small text-muted">Current Password</label>
<InputText type="password" id="currentPassword" class="form-control" @bind-Value="_passwordModel.CurrentPassword" />
<ValidationMessage For="() => _passwordModel.CurrentPassword" class="text-danger small" />
</div>
<div class="mb-3">
<label for="newPassword" class="form-label small text-muted">New Password</label>
<InputText type="password" id="newPassword" class="form-control" @bind-Value="_passwordModel.NewPassword" />
<ValidationMessage For="() => _passwordModel.NewPassword" class="text-danger small" />
<small class="text-muted">Minimum 12 characters with uppercase, lowercase, number, and special character</small>
</div>
<div class="mb-4">
<label for="confirmPassword" class="form-label small text-muted">Confirm New Password</label>
<InputText type="password" id="confirmPassword" class="form-control" @bind-Value="_passwordModel.ConfirmPassword" />
<ValidationMessage For="() => _passwordModel.ConfirmPassword" class="text-danger small" />
</div>
<button type="submit" class="btn btn-primary" disabled="@_isChangingPassword">
@if (_isChangingPassword)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Update Password
</button>
</EditForm>
</div>
</div>
<!-- Quick Links -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="fw-bold mb-4">Quick Links</h5>
<div class="list-group list-group-flush">
<a href="/account/billing" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
<div>
<strong>Billing & Subscription</strong>
<p class="mb-0 small text-muted">Manage your plan and payment method</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</a>
<a href="/dashboard" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-0 border-0">
<div>
<strong>Dashboard</strong>
<p class="mb-0 small text-muted">View your CV verification history</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@code {
private string? _userEmail;
private string _userPlan = "Free";
private DateTime? _memberSince;
private string? _successMessage;
private string? _errorMessage;
private bool _isChangingPassword;
private PasswordChangeModel _passwordModel = new();
protected override async Task OnInitializedAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
var user = await UserManager.FindByIdAsync(userId.ToString());
if (user != null)
{
_userEmail = user.Email;
_userPlan = user.Plan.ToString();
// Lockout end date is used as a proxy; in a real app you might have a CreatedAt field
_memberSince = DateTime.UtcNow.AddMonths(-1); // Placeholder
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading user settings");
_errorMessage = "Unable to load account information.";
}
}
private async Task ChangePassword()
{
if (_isChangingPassword) return;
_isChangingPassword = true;
_errorMessage = null;
_successMessage = null;
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
_errorMessage = "Unable to identify user.";
return;
}
var user = await UserManager.FindByIdAsync(userId.ToString());
if (user == null)
{
_errorMessage = "User not found.";
return;
}
var result = await UserManager.ChangePasswordAsync(
user,
_passwordModel.CurrentPassword,
_passwordModel.NewPassword);
if (result.Succeeded)
{
_successMessage = "Password updated successfully.";
_passwordModel = new PasswordChangeModel();
await SignInManager.RefreshSignInAsync(user);
}
else
{
_errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error changing password");
_errorMessage = "An error occurred. Please try again.";
}
finally
{
_isChangingPassword = false;
}
}
private class PasswordChangeModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
public string CurrentPassword { get; set; } = "";
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
[System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
public string NewPassword { get; set; } = "";
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
[System.ComponentModel.DataAnnotations.Compare("NewPassword", ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = "";
}
}

View File

@@ -3,6 +3,7 @@
@rendermode InteractiveServer
@inject ICVCheckService CVCheckService
@inject ISubscriptionService SubscriptionService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Check> Logger
@@ -21,9 +22,47 @@
</svg>
<span>For UK employment history</span>
</div>
@if (_subscription != null)
{
<div class="mt-3">
@if (_subscription.IsUnlimited)
{
<span class="badge bg-success-subtle text-success px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.798 9.137a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03zm3.911-3.911a.5.5 0 0 1-.03.706l-.01.009a.5.5 0 1 1-.676-.737l.01-.009a.5.5 0 0 1 .706.03z"/>
<path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8z"/>
<path d="M2.472 3.528a.5.5 0 0 1 .707 0l9.9 9.9a.5.5 0 0 1-.707.707l-9.9-9.9a.5.5 0 0 1 0-.707z"/>
</svg>
Unlimited checks
</span>
}
else
{
<span class="badge @(_subscription.ChecksRemaining <= 0 ? "bg-danger-subtle text-danger" : _subscription.ChecksRemaining <= 3 ? "bg-warning-subtle text-warning" : "bg-primary-subtle text-primary") px-3 py-2">
@_subscription.ChecksRemaining of @_subscription.MonthlyLimit checks remaining
</span>
}
</div>
}
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
@if (_quotaExceeded)
{
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading fw-bold mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
Monthly Limit Reached
</h5>
<p class="mb-3">You've used all your CV checks for this month. Upgrade your plan to continue verifying CVs.</p>
<a href="/pricing" class="btn btn-warning">
Upgrade Plan
</a>
</div>
}
else if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@_errorMessage
@@ -320,6 +359,8 @@
private int _currentFileIndex;
private int _totalFiles;
private string? _currentFileName;
private bool _quotaExceeded;
private SubscriptionInfoDto? _subscription;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private const int MaxFileCount = 50; // Maximum files per batch
@@ -331,6 +372,25 @@
// Buffered file to prevent stale IBrowserFile references
private sealed record BufferedFile(string Name, long Size, byte[] Data);
protected override async Task OnInitializedAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
_quotaExceeded = !_subscription.IsUnlimited && _subscription.ChecksRemaining <= 0;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error loading subscription info");
}
}
private void HandleDragEnter()
{
_isDragging = true;
@@ -466,6 +526,13 @@
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
}
catch (TrueCV.Domain.Exceptions.QuotaExceededException)
{
_quotaExceeded = true;
_errorMessage = null;
failedFiles.Add($"{file.Name} (quota exceeded)");
break; // Stop processing further files
}
catch (Exception ex)
{
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);

View File

@@ -0,0 +1,50 @@
@page "/checkout-cancel"
@rendermode InteractiveServer
@inject NavigationManager NavigationManager
<PageTitle>Checkout Cancelled - TrueCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center">
<div class="mb-4">
<div class="cancel-icon mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
</div>
<h1 class="fw-bold mb-3">Checkout Cancelled</h1>
<p class="text-muted lead mb-4">
No worries! Your checkout was cancelled and you haven't been charged. You can continue using your current plan or try upgrading again when you're ready.
</p>
</div>
<div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="/pricing" class="btn btn-primary btn-lg">
View Plans
</a>
<a href="/dashboard" class="btn btn-outline-secondary btn-lg">
Back to Dashboard
</a>
</div>
</div>
</div>
</div>
<style>
.cancel-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #dc2626;
}
</style>
@code {
}

View File

@@ -0,0 +1,83 @@
@page "/checkout-success"
@attribute [Authorize]
@rendermode InteractiveServer
@inject NavigationManager NavigationManager
<PageTitle>Payment Successful - TrueCV</PageTitle>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center">
<div class="mb-4">
<div class="success-icon mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
</div>
<h1 class="fw-bold mb-3">Payment Successful!</h1>
<p class="text-muted lead mb-4">
Thank you for upgrading your TrueCV subscription. Your account has been updated and you now have access to your new plan features.
</p>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">What's Next?</h5>
<ul class="list-unstyled text-start mb-0">
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
<span>Your new check limit is now active</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
<span>A receipt has been sent to your email</span>
</li>
<li class="d-flex align-items-start">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary me-3 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
<span>Manage your subscription in the Billing page</span>
</li>
</ul>
</div>
</div>
<div class="d-flex gap-3 justify-content-center">
<a href="/dashboard" class="btn btn-primary btn-lg">
Go to Dashboard
</a>
<a href="/check" class="btn btn-outline-primary btn-lg">
Upload CVs
</a>
</div>
</div>
</div>
</div>
<style>
.success-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #059669;
}
</style>
@code {
protected override void OnInitialized()
{
// Optionally handle the session_id query parameter if needed
}
}

View File

@@ -4,6 +4,7 @@
@implements IDisposable
@inject ICVCheckService CVCheckService
@inject ISubscriptionService SubscriptionService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Dashboard> Logger
@@ -18,6 +19,30 @@
<div>
<h1 class="fw-bold mb-1">Dashboard</h1>
<p class="text-muted mb-0">View and manage your CV verification checks</p>
@if (_subscription != null)
{
<div class="mt-2">
@if (_subscription.IsUnlimited)
{
<span class="badge bg-success-subtle text-success px-3 py-2">
Enterprise - Unlimited checks
</span>
}
else
{
var percentage = _subscription.MonthlyLimit > 0
? Math.Min(100, (_subscription.ChecksUsedThisMonth * 100) / _subscription.MonthlyLimit)
: 0;
<span class="badge @(percentage >= 90 ? "bg-danger-subtle text-danger" : percentage >= 75 ? "bg-warning-subtle text-warning" : "bg-primary-subtle text-primary") px-3 py-2">
@_subscription.Plan: @_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit checks used
</span>
@if (_subscription.ChecksRemaining <= 0)
{
<a href="/pricing" class="btn btn-sm btn-warning ms-2">Upgrade</a>
}
}
</div>
}
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
@@ -486,6 +511,7 @@
private bool _isDeleting;
private string? _errorMessage;
private Guid _userId;
private SubscriptionInfoDto? _subscription;
private System.Threading.Timer? _pollingTimer;
private volatile bool _isPolling;
private volatile bool _disposed;
@@ -579,6 +605,7 @@
}
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(_userId);
}
catch (Exception ex)
{

View File

@@ -0,0 +1,322 @@
@page "/pricing"
@rendermode InteractiveServer
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ISubscriptionService SubscriptionService
<PageTitle>Pricing - TrueCV</PageTitle>
<div class="container py-5">
<div class="text-center mb-5">
<h1 class="fw-bold mb-3">Simple, Transparent Pricing</h1>
<p class="text-muted lead mb-0" style="max-width: 600px; margin: 0 auto;">
Choose the plan that fits your hiring needs. All plans include our core CV verification technology.
</p>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
@_errorMessage
<button type="button" class="btn-close" @onclick="() => _errorMessage = null"></button>
</div>
}
<div class="row g-4 justify-content-center">
<!-- Free Plan -->
<div class="col-lg-4 col-md-6">
<div class="card border-0 shadow-sm h-100 @(_currentPlan == "Free" ? "border-primary border-2" : "")">
@if (_currentPlan == "Free")
{
<div class="card-header bg-primary text-white text-center py-2 border-0">
<small class="fw-semibold">Current Plan</small>
</div>
}
<div class="card-body p-4">
<div class="text-center mb-4">
<h4 class="fw-bold mb-1">Free</h4>
<div class="display-5 fw-bold text-primary mb-1">£0</div>
<p class="text-muted small mb-0">Forever free</p>
</div>
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span><strong>3 CV checks</strong> per month</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Companies House verification</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Timeline gap analysis</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>PDF reports</span>
</li>
</ul>
@if (_currentPlan == "Free")
{
<button class="btn btn-outline-secondary w-100 py-2" disabled>
Current Plan
</button>
}
else
{
<button class="btn btn-outline-primary w-100 py-2" disabled>
Downgrade via Portal
</button>
}
</div>
</div>
</div>
<!-- Professional Plan -->
<div class="col-lg-4 col-md-6">
<div class="card border-0 shadow h-100 position-relative @(_currentPlan == "Professional" ? "border-primary border-2" : "")">
@if (_currentPlan == "Professional")
{
<div class="card-header bg-primary text-white text-center py-2 border-0">
<small class="fw-semibold">Current Plan</small>
</div>
}
else
{
<div class="position-absolute top-0 start-50 translate-middle">
<span class="badge bg-primary px-3 py-2">Most Popular</span>
</div>
}
<div class="card-body p-4 @(_currentPlan != "Professional" ? "pt-5" : "")">
<div class="text-center mb-4">
<h4 class="fw-bold mb-1">Professional</h4>
<div class="display-5 fw-bold text-primary mb-1">£49</div>
<p class="text-muted small mb-0">per month</p>
</div>
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span><strong>30 CV checks</strong> per month</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Everything in Free</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Priority processing</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Email support</span>
</li>
</ul>
@if (_currentPlan == "Professional")
{
<button class="btn btn-outline-secondary w-100 py-2" disabled>
Current Plan
</button>
}
else if (_currentPlan == "Enterprise")
{
<button class="btn btn-outline-primary w-100 py-2" disabled>
Downgrade via Portal
</button>
}
else
{
<form action="/api/billing/create-checkout" method="post">
<input type="hidden" name="plan" value="Professional" />
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold" disabled="@(!_isAuthenticated)">
@if (_isAuthenticated)
{
<span>Upgrade to Professional</span>
}
else
{
<span>Sign in to Upgrade</span>
}
</button>
</form>
}
</div>
</div>
</div>
<!-- Enterprise Plan -->
<div class="col-lg-4 col-md-6">
<div class="card border-0 shadow-sm h-100 @(_currentPlan == "Enterprise" ? "border-primary border-2" : "")">
@if (_currentPlan == "Enterprise")
{
<div class="card-header bg-primary text-white text-center py-2 border-0">
<small class="fw-semibold">Current Plan</small>
</div>
}
<div class="card-body p-4">
<div class="text-center mb-4">
<h4 class="fw-bold mb-1">Enterprise</h4>
<div class="display-5 fw-bold text-primary mb-1">£199</div>
<p class="text-muted small mb-0">per month</p>
</div>
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span><strong>Unlimited</strong> CV checks</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Everything in Professional</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>API access</span>
</li>
<li class="d-flex align-items-start mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-success me-2 flex-shrink-0 mt-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
<span>Dedicated support</span>
</li>
</ul>
@if (_currentPlan == "Enterprise")
{
<button class="btn btn-outline-secondary w-100 py-2" disabled>
Current Plan
</button>
}
else
{
<form action="/api/billing/create-checkout" method="post">
<input type="hidden" name="plan" value="Enterprise" />
<button type="submit" class="btn btn-dark w-100 py-2 fw-semibold" disabled="@(!_isAuthenticated)">
@if (_isAuthenticated)
{
<span>Upgrade to Enterprise</span>
}
else
{
<span>Sign in to Upgrade</span>
}
</button>
</form>
}
</div>
</div>
</div>
</div>
@if (!_isAuthenticated)
{
<div class="text-center mt-4">
<a href="/account/login?returnUrl=/pricing" class="btn btn-link">
Already have an account? Sign in to upgrade
</a>
</div>
}
<!-- FAQ Section -->
<div class="mt-5 pt-5">
<h3 class="fw-bold text-center mb-4">Frequently Asked Questions</h3>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="accordion" id="pricingFaq">
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
<h2 class="accordion-header">
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
What happens when I reach my monthly limit?
</button>
</h2>
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div class="accordion-body">
Once you reach your monthly CV check limit, you'll need to upgrade to a higher plan or wait until your billing cycle resets. Your existing reports remain accessible.
</div>
</div>
</div>
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
<h2 class="accordion-header">
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
Can I cancel my subscription anytime?
</button>
</h2>
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div class="accordion-body">
Yes, you can cancel your subscription at any time. You'll continue to have access to your paid features until the end of your current billing period.
</div>
</div>
</div>
<div class="accordion-item border-0 mb-3 shadow-sm rounded">
<h2 class="accordion-header">
<button class="accordion-button collapsed rounded fw-semibold" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
How accurate is the CV verification?
</button>
</h2>
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#pricingFaq">
<div class="accordion-body">
We verify employment claims against official Companies House records and use AI-powered matching to handle name variations. Our system detects discrepancies in company names, dates, and timelines with high accuracy.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@code {
private bool _isAuthenticated;
private string _currentPlan = "Free";
private string? _errorMessage;
protected override async Task OnInitializedAsync()
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query).TryGetValue("error", out var error))
{
_errorMessage = error == "checkout_failed"
? "Unable to start checkout. Please try again."
: error.ToString();
}
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
_isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
if (_isAuthenticated)
{
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && Guid.TryParse(userIdClaim, out var userId))
{
var subscription = await SubscriptionService.GetSubscriptionInfoAsync(userId);
_currentPlan = subscription.Plan.ToString();
}
}
}
}