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:
16
src/TrueCV.Application/DTOs/SubscriptionInfoDto.cs
Normal file
16
src/TrueCV.Application/DTOs/SubscriptionInfoDto.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
|
||||||
|
namespace TrueCV.Application.DTOs;
|
||||||
|
|
||||||
|
public class SubscriptionInfoDto
|
||||||
|
{
|
||||||
|
public UserPlan Plan { get; set; }
|
||||||
|
public int ChecksUsedThisMonth { get; set; }
|
||||||
|
public int MonthlyLimit { get; set; }
|
||||||
|
public int ChecksRemaining { get; set; }
|
||||||
|
public bool IsUnlimited { get; set; }
|
||||||
|
public string? SubscriptionStatus { get; set; }
|
||||||
|
public DateTime? CurrentPeriodEnd { get; set; }
|
||||||
|
public bool HasActiveSubscription { get; set; }
|
||||||
|
public string DisplayPrice { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
10
src/TrueCV.Application/Interfaces/IStripeService.cs
Normal file
10
src/TrueCV.Application/Interfaces/IStripeService.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
|
||||||
|
namespace TrueCV.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IStripeService
|
||||||
|
{
|
||||||
|
Task<string> CreateCheckoutSessionAsync(Guid userId, string email, UserPlan targetPlan, string successUrl, string cancelUrl);
|
||||||
|
Task<string> CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl);
|
||||||
|
Task HandleWebhookAsync(string json, string signature);
|
||||||
|
}
|
||||||
11
src/TrueCV.Application/Interfaces/ISubscriptionService.cs
Normal file
11
src/TrueCV.Application/Interfaces/ISubscriptionService.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using TrueCV.Application.DTOs;
|
||||||
|
|
||||||
|
namespace TrueCV.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ISubscriptionService
|
||||||
|
{
|
||||||
|
Task<bool> CanPerformCheckAsync(Guid userId);
|
||||||
|
Task IncrementUsageAsync(Guid userId);
|
||||||
|
Task ResetUsageAsync(Guid userId);
|
||||||
|
Task<SubscriptionInfoDto> GetSubscriptionInfoAsync(Guid userId);
|
||||||
|
}
|
||||||
31
src/TrueCV.Domain/Constants/PlanLimits.cs
Normal file
31
src/TrueCV.Domain/Constants/PlanLimits.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
|
||||||
|
namespace TrueCV.Domain.Constants;
|
||||||
|
|
||||||
|
public static class PlanLimits
|
||||||
|
{
|
||||||
|
public static int GetMonthlyLimit(UserPlan plan) => plan switch
|
||||||
|
{
|
||||||
|
UserPlan.Free => 3,
|
||||||
|
UserPlan.Professional => 30,
|
||||||
|
UserPlan.Enterprise => int.MaxValue,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
public static int GetPricePence(UserPlan plan) => plan switch
|
||||||
|
{
|
||||||
|
UserPlan.Professional => 4900,
|
||||||
|
UserPlan.Enterprise => 19900,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetDisplayPrice(UserPlan plan) => plan switch
|
||||||
|
{
|
||||||
|
UserPlan.Free => "Free",
|
||||||
|
UserPlan.Professional => "£49/month",
|
||||||
|
UserPlan.Enterprise => "£199/month",
|
||||||
|
_ => "Unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsUnlimited(UserPlan plan) => plan == UserPlan.Enterprise;
|
||||||
|
}
|
||||||
19
src/TrueCV.Domain/Exceptions/QuotaExceededException.cs
Normal file
19
src/TrueCV.Domain/Exceptions/QuotaExceededException.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TrueCV.Domain.Exceptions;
|
||||||
|
|
||||||
|
public class QuotaExceededException : Exception
|
||||||
|
{
|
||||||
|
public QuotaExceededException()
|
||||||
|
: base("Monthly CV check quota exceeded. Please upgrade your plan.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuotaExceededException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuotaExceededException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/TrueCV.Infrastructure/Configuration/StripeSettings.cs
Normal file
17
src/TrueCV.Infrastructure/Configuration/StripeSettings.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TrueCV.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public class StripeSettings
|
||||||
|
{
|
||||||
|
public const string SectionName = "Stripe";
|
||||||
|
|
||||||
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
|
public string PublishableKey { get; set; } = string.Empty;
|
||||||
|
public string WebhookSecret { get; set; } = string.Empty;
|
||||||
|
public StripePriceIds PriceIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StripePriceIds
|
||||||
|
{
|
||||||
|
public string Professional { get; set; } = string.Empty;
|
||||||
|
public string Enterprise { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -40,6 +40,18 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
|
|||||||
entity.Property(u => u.StripeCustomerId)
|
entity.Property(u => u.StripeCustomerId)
|
||||||
.HasMaxLength(256);
|
.HasMaxLength(256);
|
||||||
|
|
||||||
|
entity.Property(u => u.StripeSubscriptionId)
|
||||||
|
.HasMaxLength(256);
|
||||||
|
|
||||||
|
entity.Property(u => u.SubscriptionStatus)
|
||||||
|
.HasMaxLength(32);
|
||||||
|
|
||||||
|
entity.HasIndex(u => u.StripeCustomerId)
|
||||||
|
.HasDatabaseName("IX_Users_StripeCustomerId");
|
||||||
|
|
||||||
|
entity.HasIndex(u => u.StripeSubscriptionId)
|
||||||
|
.HasDatabaseName("IX_Users_StripeSubscriptionId");
|
||||||
|
|
||||||
entity.HasMany(u => u.CVChecks)
|
entity.HasMany(u => u.CVChecks)
|
||||||
.WithOne()
|
.WithOne()
|
||||||
.HasForeignKey(c => c.UserId)
|
.HasForeignKey(c => c.UserId)
|
||||||
|
|||||||
519
src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.Designer.cs
generated
Normal file
519
src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.Designer.cs
generated
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using TrueCV.Infrastructure.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260121115517_AddStripeSubscriptionFields")]
|
||||||
|
partial class AddStripeSubscriptionFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.23")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex")
|
||||||
|
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("EntityId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Action")
|
||||||
|
.HasDatabaseName("IX_AuditLogs_Action");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt")
|
||||||
|
.HasDatabaseName("IX_AuditLogs_CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("IX_AuditLogs_UserId");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("BlobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ExtractedDataJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ProcessingStage")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ReportJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("VeracityScore")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasDatabaseName("IX_CVChecks_Status");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("IX_CVChecks_UserId");
|
||||||
|
|
||||||
|
b.ToTable("CVChecks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid>("CVCheckId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("ScoreImpact")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Severity")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CVCheckId")
|
||||||
|
.HasDatabaseName("IX_CVFlags_CVCheckId");
|
||||||
|
|
||||||
|
b.ToTable("CVFlags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("CompanyNumber")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("AccountsCategory")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CachedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CompanyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("CompanyType")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("DissolutionDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("IncorporationDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("SicCodesJson")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("CompanyNumber");
|
||||||
|
|
||||||
|
b.ToTable("CompanyCache");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ChecksUsedThisMonth")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CurrentPeriodEnd")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Plan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeCustomerId")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeSubscriptionId")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionStatus")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex")
|
||||||
|
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("StripeCustomerId")
|
||||||
|
.HasDatabaseName("IX_Users_StripeCustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("StripeSubscriptionId")
|
||||||
|
.HasDatabaseName("IX_Users_StripeSubscriptionId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
|
||||||
|
.WithMany("CVChecks")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck")
|
||||||
|
.WithMany("Flags")
|
||||||
|
.HasForeignKey("CVCheckId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CVCheck");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Flags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("CVChecks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStripeSubscriptionFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CurrentPeriodEnd",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "StripeSubscriptionId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "nvarchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SubscriptionStatus",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "nvarchar(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_StripeCustomerId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "StripeCustomerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Users_StripeSubscriptionId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "StripeSubscriptionId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_StripeCustomerId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Users_StripeSubscriptionId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentPeriodEnd",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "StripeSubscriptionId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubscriptionStatus",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -351,6 +351,9 @@ namespace TrueCV.Infrastructure.Data.Migrations
|
|||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CurrentPeriodEnd")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("nvarchar(256)");
|
.HasColumnType("nvarchar(256)");
|
||||||
@@ -393,6 +396,14 @@ namespace TrueCV.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("nvarchar(256)");
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("StripeSubscriptionId")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionStatus")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -410,6 +421,12 @@ namespace TrueCV.Infrastructure.Data.Migrations
|
|||||||
.HasDatabaseName("UserNameIndex")
|
.HasDatabaseName("UserNameIndex")
|
||||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("StripeCustomerId")
|
||||||
|
.HasDatabaseName("IX_Users_StripeCustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("StripeSubscriptionId")
|
||||||
|
.HasDatabaseName("IX_Users_StripeSubscriptionId");
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Extensions.Http;
|
using Polly.Extensions.Http;
|
||||||
|
using Stripe;
|
||||||
using TrueCV.Application.Interfaces;
|
using TrueCV.Application.Interfaces;
|
||||||
using TrueCV.Infrastructure.Configuration;
|
using TrueCV.Infrastructure.Configuration;
|
||||||
using TrueCV.Infrastructure.Data;
|
using TrueCV.Infrastructure.Data;
|
||||||
@@ -74,6 +75,16 @@ public static class DependencyInjection
|
|||||||
services.Configure<LocalStorageSettings>(
|
services.Configure<LocalStorageSettings>(
|
||||||
configuration.GetSection(LocalStorageSettings.SectionName));
|
configuration.GetSection(LocalStorageSettings.SectionName));
|
||||||
|
|
||||||
|
services.Configure<StripeSettings>(
|
||||||
|
configuration.GetSection(StripeSettings.SectionName));
|
||||||
|
|
||||||
|
// Configure Stripe API key
|
||||||
|
var stripeSettings = configuration.GetSection(StripeSettings.SectionName).Get<StripeSettings>();
|
||||||
|
if (!string.IsNullOrEmpty(stripeSettings?.SecretKey))
|
||||||
|
{
|
||||||
|
StripeConfiguration.ApiKey = stripeSettings.SecretKey;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure HttpClient for CompaniesHouseClient with retry policy
|
// Configure HttpClient for CompaniesHouseClient with retry policy
|
||||||
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
|
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
|
||||||
{
|
{
|
||||||
@@ -97,6 +108,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICVCheckService, CVCheckService>();
|
services.AddScoped<ICVCheckService, CVCheckService>();
|
||||||
services.AddScoped<IUserContextService, UserContextService>();
|
services.AddScoped<IUserContextService, UserContextService>();
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
services.AddScoped<IStripeService, StripeService>();
|
||||||
|
services.AddScoped<ISubscriptionService, Services.SubscriptionService>();
|
||||||
|
|
||||||
// Register file storage - use local storage if configured, otherwise Azure
|
// Register file storage - use local storage if configured, otherwise Azure
|
||||||
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
|
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
|
||||||
@@ -111,6 +124,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Register Hangfire jobs
|
// Register Hangfire jobs
|
||||||
services.AddTransient<ProcessCVCheckJob>();
|
services.AddTransient<ProcessCVCheckJob>();
|
||||||
|
services.AddTransient<ResetMonthlyUsageJob>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ public class ApplicationUser : IdentityUser<Guid>
|
|||||||
|
|
||||||
public string? StripeCustomerId { get; set; }
|
public string? StripeCustomerId { get; set; }
|
||||||
|
|
||||||
|
public string? StripeSubscriptionId { get; set; }
|
||||||
|
|
||||||
|
public string? SubscriptionStatus { get; set; }
|
||||||
|
|
||||||
|
public DateTime? CurrentPeriodEnd { get; set; }
|
||||||
|
|
||||||
public int ChecksUsedThisMonth { get; set; }
|
public int ChecksUsedThisMonth { get; set; }
|
||||||
|
|
||||||
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
|
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
|
||||||
|
|||||||
80
src/TrueCV.Infrastructure/Jobs/ResetMonthlyUsageJob.cs
Normal file
80
src/TrueCV.Infrastructure/Jobs/ResetMonthlyUsageJob.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
using TrueCV.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hangfire job that resets monthly CV check usage for users whose billing period has ended.
|
||||||
|
/// This job should run daily to catch users whose subscriptions renewed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ResetMonthlyUsageJob
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
private readonly ILogger<ResetMonthlyUsageJob> _logger;
|
||||||
|
|
||||||
|
public ResetMonthlyUsageJob(
|
||||||
|
ApplicationDbContext dbContext,
|
||||||
|
ILogger<ResetMonthlyUsageJob> logger)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting monthly usage reset job");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Reset usage for paid users whose billing period has ended
|
||||||
|
// The webhook handler already resets usage when subscription renews,
|
||||||
|
// but this catches any edge cases or delays in webhook delivery
|
||||||
|
var paidUsersToReset = await _dbContext.Users
|
||||||
|
.Where(u => u.Plan != UserPlan.Free)
|
||||||
|
.Where(u => u.CurrentPeriodEnd != null && u.CurrentPeriodEnd <= now)
|
||||||
|
.Where(u => u.ChecksUsedThisMonth > 0)
|
||||||
|
.Where(u => u.SubscriptionStatus == "active")
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var user in paidUsersToReset)
|
||||||
|
{
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Reset usage for paid user {UserId} - billing period ended {PeriodEnd}",
|
||||||
|
user.Id, user.CurrentPeriodEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset usage for free users on the 1st of each month
|
||||||
|
if (now.Day == 1)
|
||||||
|
{
|
||||||
|
var freeUsersToReset = await _dbContext.Users
|
||||||
|
.Where(u => u.Plan == UserPlan.Free)
|
||||||
|
.Where(u => u.ChecksUsedThisMonth > 0)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var user in freeUsersToReset)
|
||||||
|
{
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
_logger.LogInformation("Reset usage for free user {UserId}", user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Reset usage for {Count} free users", freeUsersToReset.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Monthly usage reset job completed. Reset {PaidCount} paid users",
|
||||||
|
paidUsersToReset.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in monthly usage reset job");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using TrueCV.Application.Interfaces;
|
|||||||
using TrueCV.Application.Models;
|
using TrueCV.Application.Models;
|
||||||
using TrueCV.Domain.Entities;
|
using TrueCV.Domain.Entities;
|
||||||
using TrueCV.Domain.Enums;
|
using TrueCV.Domain.Enums;
|
||||||
|
using TrueCV.Domain.Exceptions;
|
||||||
using TrueCV.Infrastructure.Data;
|
using TrueCV.Infrastructure.Data;
|
||||||
using TrueCV.Infrastructure.Jobs;
|
using TrueCV.Infrastructure.Jobs;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public sealed class CVCheckService : ICVCheckService
|
|||||||
private readonly IFileStorageService _fileStorageService;
|
private readonly IFileStorageService _fileStorageService;
|
||||||
private readonly IBackgroundJobClient _backgroundJobClient;
|
private readonly IBackgroundJobClient _backgroundJobClient;
|
||||||
private readonly IAuditService _auditService;
|
private readonly IAuditService _auditService;
|
||||||
|
private readonly ISubscriptionService _subscriptionService;
|
||||||
private readonly ILogger<CVCheckService> _logger;
|
private readonly ILogger<CVCheckService> _logger;
|
||||||
|
|
||||||
public CVCheckService(
|
public CVCheckService(
|
||||||
@@ -26,12 +28,14 @@ public sealed class CVCheckService : ICVCheckService
|
|||||||
IFileStorageService fileStorageService,
|
IFileStorageService fileStorageService,
|
||||||
IBackgroundJobClient backgroundJobClient,
|
IBackgroundJobClient backgroundJobClient,
|
||||||
IAuditService auditService,
|
IAuditService auditService,
|
||||||
|
ISubscriptionService subscriptionService,
|
||||||
ILogger<CVCheckService> logger)
|
ILogger<CVCheckService> logger)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_fileStorageService = fileStorageService;
|
_fileStorageService = fileStorageService;
|
||||||
_backgroundJobClient = backgroundJobClient;
|
_backgroundJobClient = backgroundJobClient;
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
|
_subscriptionService = subscriptionService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,13 @@ public sealed class CVCheckService : ICVCheckService
|
|||||||
|
|
||||||
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
|
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
|
||||||
|
|
||||||
|
// Check quota before proceeding
|
||||||
|
if (!await _subscriptionService.CanPerformCheckAsync(userId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User {UserId} quota exceeded - CV check denied", userId);
|
||||||
|
throw new QuotaExceededException();
|
||||||
|
}
|
||||||
|
|
||||||
// Upload file to blob storage
|
// Upload file to blob storage
|
||||||
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
|
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
|
||||||
|
|
||||||
@@ -71,6 +82,9 @@ public sealed class CVCheckService : ICVCheckService
|
|||||||
|
|
||||||
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
|
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
|
||||||
|
|
||||||
|
// Increment usage after successful creation
|
||||||
|
await _subscriptionService.IncrementUsageAsync(userId);
|
||||||
|
|
||||||
return cvCheck.Id;
|
return cvCheck.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
316
src/TrueCV.Infrastructure/Services/StripeService.cs
Normal file
316
src/TrueCV.Infrastructure/Services/StripeService.cs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Stripe;
|
||||||
|
using Stripe.Checkout;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
using TrueCV.Infrastructure.Configuration;
|
||||||
|
using TrueCV.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class StripeService : IStripeService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
private readonly StripeSettings _settings;
|
||||||
|
private readonly ILogger<StripeService> _logger;
|
||||||
|
|
||||||
|
public StripeService(
|
||||||
|
ApplicationDbContext dbContext,
|
||||||
|
IOptions<StripeSettings> settings,
|
||||||
|
ILogger<StripeService> logger)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
StripeConfiguration.ApiKey = _settings.SecretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateCheckoutSessionAsync(
|
||||||
|
Guid userId,
|
||||||
|
string email,
|
||||||
|
UserPlan targetPlan,
|
||||||
|
string successUrl,
|
||||||
|
string cancelUrl)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Creating checkout session for user {UserId}, plan {Plan}", userId, targetPlan);
|
||||||
|
|
||||||
|
var priceId = targetPlan switch
|
||||||
|
{
|
||||||
|
UserPlan.Professional => _settings.PriceIds.Professional,
|
||||||
|
UserPlan.Enterprise => _settings.PriceIds.Enterprise,
|
||||||
|
_ => throw new ArgumentException($"Invalid plan for checkout: {targetPlan}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(priceId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Price ID not configured for plan: {targetPlan}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users.FindAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"User not found: {userId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionOptions = new SessionCreateOptions
|
||||||
|
{
|
||||||
|
Mode = "subscription",
|
||||||
|
CustomerEmail = string.IsNullOrEmpty(user.StripeCustomerId) ? email : null,
|
||||||
|
Customer = !string.IsNullOrEmpty(user.StripeCustomerId) ? user.StripeCustomerId : null,
|
||||||
|
LineItems = new List<SessionLineItemOptions>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Price = priceId,
|
||||||
|
Quantity = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
|
||||||
|
CancelUrl = cancelUrl,
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "user_id", userId.ToString() },
|
||||||
|
{ "target_plan", targetPlan.ToString() }
|
||||||
|
},
|
||||||
|
SubscriptionData = new SessionSubscriptionDataOptions
|
||||||
|
{
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "user_id", userId.ToString() },
|
||||||
|
{ "plan", targetPlan.ToString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var sessionService = new SessionService();
|
||||||
|
var session = await sessionService.CreateAsync(sessionOptions);
|
||||||
|
|
||||||
|
_logger.LogInformation("Checkout session created: {SessionId}", session.Id);
|
||||||
|
|
||||||
|
return session.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Creating customer portal session for customer {CustomerId}", stripeCustomerId);
|
||||||
|
|
||||||
|
var options = new Stripe.BillingPortal.SessionCreateOptions
|
||||||
|
{
|
||||||
|
Customer = stripeCustomerId,
|
||||||
|
ReturnUrl = returnUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = new Stripe.BillingPortal.SessionService();
|
||||||
|
var session = await service.CreateAsync(options);
|
||||||
|
|
||||||
|
return session.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleWebhookAsync(string json, string signature)
|
||||||
|
{
|
||||||
|
Event stripeEvent;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stripeEvent = EventUtility.ConstructEvent(json, signature, _settings.WebhookSecret);
|
||||||
|
}
|
||||||
|
catch (StripeException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Webhook signature verification failed");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Processing webhook event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
|
||||||
|
|
||||||
|
switch (stripeEvent.Type)
|
||||||
|
{
|
||||||
|
case EventTypes.CheckoutSessionCompleted:
|
||||||
|
await HandleCheckoutSessionCompleted(stripeEvent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventTypes.CustomerSubscriptionUpdated:
|
||||||
|
await HandleSubscriptionUpdated(stripeEvent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventTypes.CustomerSubscriptionDeleted:
|
||||||
|
await HandleSubscriptionDeleted(stripeEvent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EventTypes.InvoicePaymentFailed:
|
||||||
|
await HandlePaymentFailed(stripeEvent);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_logger.LogDebug("Unhandled webhook event type: {EventType}", stripeEvent.Type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCheckoutSessionCompleted(Event stripeEvent)
|
||||||
|
{
|
||||||
|
var session = stripeEvent.Data.Object as Session;
|
||||||
|
if (session == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not parse checkout session from event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIdString = session.Metadata.GetValueOrDefault("user_id");
|
||||||
|
var targetPlanString = session.Metadata.GetValueOrDefault("target_plan");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userIdString) || !Guid.TryParse(userIdString, out var userId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Missing or invalid user_id in checkout session metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(targetPlanString) || !Enum.TryParse<UserPlan>(targetPlanString, out var targetPlan))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Missing or invalid target_plan in checkout session metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users.FindAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User not found for checkout session: {UserId}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.StripeCustomerId = session.CustomerId;
|
||||||
|
user.StripeSubscriptionId = session.SubscriptionId;
|
||||||
|
user.Plan = targetPlan;
|
||||||
|
user.SubscriptionStatus = "active";
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
|
||||||
|
// Fetch subscription to get period end (from the first item)
|
||||||
|
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
||||||
|
{
|
||||||
|
var stripeSubscriptionService = new Stripe.SubscriptionService();
|
||||||
|
var stripeSubscription = await stripeSubscriptionService.GetAsync(session.SubscriptionId);
|
||||||
|
var firstItem = stripeSubscription.Items?.Data?.FirstOrDefault();
|
||||||
|
if (firstItem != null)
|
||||||
|
{
|
||||||
|
user.CurrentPeriodEnd = firstItem.CurrentPeriodEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {UserId} upgraded to {Plan} via checkout session {SessionId}",
|
||||||
|
userId, targetPlan, session.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubscriptionUpdated(Event stripeEvent)
|
||||||
|
{
|
||||||
|
var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
|
if (stripeSubscription == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not parse subscription from event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.StripeSubscriptionId == stripeSubscription.Id);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No user found for subscription: {SubscriptionId}", stripeSubscription.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousStatus = user.SubscriptionStatus;
|
||||||
|
var previousPeriodEnd = user.CurrentPeriodEnd;
|
||||||
|
|
||||||
|
user.SubscriptionStatus = stripeSubscription.Status;
|
||||||
|
|
||||||
|
// Get period end from first subscription item
|
||||||
|
var firstItem = stripeSubscription.Items?.Data?.FirstOrDefault();
|
||||||
|
var newPeriodEnd = firstItem?.CurrentPeriodEnd;
|
||||||
|
user.CurrentPeriodEnd = newPeriodEnd;
|
||||||
|
|
||||||
|
// Reset usage if billing period renewed
|
||||||
|
if (previousPeriodEnd.HasValue &&
|
||||||
|
newPeriodEnd.HasValue &&
|
||||||
|
newPeriodEnd.Value > previousPeriodEnd.Value &&
|
||||||
|
stripeSubscription.Status == "active")
|
||||||
|
{
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
_logger.LogInformation("Reset monthly usage for user {UserId} - new billing period", user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plan changes from Stripe portal
|
||||||
|
var planString = stripeSubscription.Metadata.GetValueOrDefault("plan");
|
||||||
|
if (!string.IsNullOrEmpty(planString) && Enum.TryParse<UserPlan>(planString, out var plan))
|
||||||
|
{
|
||||||
|
user.Plan = plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Subscription updated for user {UserId}: status {Status}, period end {PeriodEnd}",
|
||||||
|
user.Id, stripeSubscription.Status, newPeriodEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubscriptionDeleted(Event stripeEvent)
|
||||||
|
{
|
||||||
|
var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
|
||||||
|
if (stripeSubscription == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not parse subscription from event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.StripeSubscriptionId == stripeSubscription.Id);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No user found for deleted subscription: {SubscriptionId}", stripeSubscription.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Plan = UserPlan.Free;
|
||||||
|
user.StripeSubscriptionId = null;
|
||||||
|
user.SubscriptionStatus = null;
|
||||||
|
user.CurrentPeriodEnd = null;
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {UserId} downgraded to Free plan - subscription {SubscriptionId} deleted",
|
||||||
|
user.Id, stripeSubscription.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePaymentFailed(Event stripeEvent)
|
||||||
|
{
|
||||||
|
var invoice = stripeEvent.Data.Object as Invoice;
|
||||||
|
if (invoice == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not parse invoice from event");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.StripeCustomerId == invoice.CustomerId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No user found for customer: {CustomerId}", invoice.CustomerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.SubscriptionStatus = "past_due";
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Payment failed for user {UserId}, invoice {InvoiceId}. Subscription marked as past_due.",
|
||||||
|
user.Id, invoice.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/TrueCV.Infrastructure/Services/SubscriptionService.cs
Normal file
133
src/TrueCV.Infrastructure/Services/SubscriptionService.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TrueCV.Application.DTOs;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
using TrueCV.Domain.Constants;
|
||||||
|
using TrueCV.Domain.Enums;
|
||||||
|
using TrueCV.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace TrueCV.Infrastructure.Services;
|
||||||
|
|
||||||
|
public sealed class SubscriptionService : ISubscriptionService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _dbContext;
|
||||||
|
private readonly ILogger<SubscriptionService> _logger;
|
||||||
|
|
||||||
|
public SubscriptionService(
|
||||||
|
ApplicationDbContext dbContext,
|
||||||
|
ILogger<SubscriptionService> logger)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CanPerformCheckAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User not found for quota check: {UserId}", userId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enterprise users have unlimited checks
|
||||||
|
if (PlanLimits.IsUnlimited(user.Plan))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscription is in good standing for paid plans
|
||||||
|
if (user.Plan != UserPlan.Free)
|
||||||
|
{
|
||||||
|
if (user.SubscriptionStatus == "canceled" || user.SubscriptionStatus == "unpaid")
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"User {UserId} subscription status is {Status} - denying check",
|
||||||
|
userId, user.SubscriptionStatus);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit = PlanLimits.GetMonthlyLimit(user.Plan);
|
||||||
|
var canPerform = user.ChecksUsedThisMonth < limit;
|
||||||
|
|
||||||
|
if (!canPerform)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {UserId} has reached quota: {Used}/{Limit} checks",
|
||||||
|
userId, user.ChecksUsedThisMonth, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canPerform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task IncrementUsageAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users.FindAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User not found for usage increment: {UserId}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ChecksUsedThisMonth++;
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Incremented usage for user {UserId}: {Count} checks this month",
|
||||||
|
userId, user.ChecksUsedThisMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetUsageAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users.FindAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User not found for usage reset: {UserId}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.ChecksUsedThisMonth = 0;
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Reset monthly usage for user {UserId}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SubscriptionInfoDto> GetSubscriptionInfoAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await _dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("User not found for subscription info: {UserId}", userId);
|
||||||
|
return new SubscriptionInfoDto
|
||||||
|
{
|
||||||
|
Plan = UserPlan.Free,
|
||||||
|
MonthlyLimit = PlanLimits.GetMonthlyLimit(UserPlan.Free),
|
||||||
|
DisplayPrice = PlanLimits.GetDisplayPrice(UserPlan.Free)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var limit = PlanLimits.GetMonthlyLimit(user.Plan);
|
||||||
|
var isUnlimited = PlanLimits.IsUnlimited(user.Plan);
|
||||||
|
|
||||||
|
return new SubscriptionInfoDto
|
||||||
|
{
|
||||||
|
Plan = user.Plan,
|
||||||
|
ChecksUsedThisMonth = user.ChecksUsedThisMonth,
|
||||||
|
MonthlyLimit = limit,
|
||||||
|
ChecksRemaining = isUnlimited ? int.MaxValue : Math.Max(0, limit - user.ChecksUsedThisMonth),
|
||||||
|
IsUnlimited = isUnlimited,
|
||||||
|
SubscriptionStatus = user.SubscriptionStatus,
|
||||||
|
CurrentPeriodEnd = user.CurrentPeriodEnd,
|
||||||
|
HasActiveSubscription = !string.IsNullOrEmpty(user.StripeSubscriptionId) &&
|
||||||
|
(user.SubscriptionStatus == "active" || user.SubscriptionStatus == "past_due"),
|
||||||
|
DisplayPrice = PlanLimits.GetDisplayPrice(user.Plan)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
|
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="50.2.0" />
|
||||||
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
|
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
220
src/TrueCV.Web/Components/Pages/Account/Billing.razor
Normal file
220
src/TrueCV.Web/Components/Pages/Account/Billing.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/TrueCV.Web/Components/Pages/Account/Settings.razor
Normal file
235
src/TrueCV.Web/Components/Pages/Account/Settings.razor
Normal 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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@inject ICVCheckService CVCheckService
|
@inject ICVCheckService CVCheckService
|
||||||
|
@inject ISubscriptionService SubscriptionService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject ILogger<Check> Logger
|
@inject ILogger<Check> Logger
|
||||||
@@ -21,9 +22,47 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>For UK employment history</span>
|
<span>For UK employment history</span>
|
||||||
</div>
|
</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>
|
</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">
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
@_errorMessage
|
@_errorMessage
|
||||||
@@ -320,6 +359,8 @@
|
|||||||
private int _currentFileIndex;
|
private int _currentFileIndex;
|
||||||
private int _totalFiles;
|
private int _totalFiles;
|
||||||
private string? _currentFileName;
|
private string? _currentFileName;
|
||||||
|
private bool _quotaExceeded;
|
||||||
|
private SubscriptionInfoDto? _subscription;
|
||||||
|
|
||||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||||
private const int MaxFileCount = 50; // Maximum files per batch
|
private const int MaxFileCount = 50; // Maximum files per batch
|
||||||
@@ -331,6 +372,25 @@
|
|||||||
// Buffered file to prevent stale IBrowserFile references
|
// Buffered file to prevent stale IBrowserFile references
|
||||||
private sealed record BufferedFile(string Name, long Size, byte[] Data);
|
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()
|
private void HandleDragEnter()
|
||||||
{
|
{
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
@@ -466,6 +526,13 @@
|
|||||||
|
|
||||||
await CVCheckService.CreateCheckAsync(userId, memoryStream, file.Name);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
|
Logger.LogError(ex, "Error uploading CV: {FileName}", file.Name);
|
||||||
|
|||||||
50
src/TrueCV.Web/Components/Pages/CheckoutCancel.razor
Normal file
50
src/TrueCV.Web/Components/Pages/CheckoutCancel.razor
Normal 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 {
|
||||||
|
}
|
||||||
83
src/TrueCV.Web/Components/Pages/CheckoutSuccess.razor
Normal file
83
src/TrueCV.Web/Components/Pages/CheckoutSuccess.razor
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@inject ICVCheckService CVCheckService
|
@inject ICVCheckService CVCheckService
|
||||||
|
@inject ISubscriptionService SubscriptionService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject ILogger<Dashboard> Logger
|
@inject ILogger<Dashboard> Logger
|
||||||
@@ -18,6 +19,30 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="fw-bold mb-1">Dashboard</h1>
|
<h1 class="fw-bold mb-1">Dashboard</h1>
|
||||||
<p class="text-muted mb-0">View and manage your CV verification checks</p>
|
<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>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
|
<button class="btn btn-outline-secondary" @onclick="ExportToPdf" disabled="@(_isExporting || !HasCompletedChecks())">
|
||||||
@@ -486,6 +511,7 @@
|
|||||||
private bool _isDeleting;
|
private bool _isDeleting;
|
||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private Guid _userId;
|
private Guid _userId;
|
||||||
|
private SubscriptionInfoDto? _subscription;
|
||||||
private System.Threading.Timer? _pollingTimer;
|
private System.Threading.Timer? _pollingTimer;
|
||||||
private volatile bool _isPolling;
|
private volatile bool _isPolling;
|
||||||
private volatile bool _disposed;
|
private volatile bool _disposed;
|
||||||
@@ -579,6 +605,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
|
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
|
||||||
|
_subscription = await SubscriptionService.GetSubscriptionInfoAsync(_userId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
322
src/TrueCV.Web/Components/Pages/Pricing.razor
Normal file
322
src/TrueCV.Web/Components/Pages/Pricing.razor
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using TrueCV.Application.Interfaces;
|
||||||
|
using TrueCV.Domain.Enums;
|
||||||
using TrueCV.Infrastructure;
|
using TrueCV.Infrastructure;
|
||||||
using TrueCV.Infrastructure.Data;
|
using TrueCV.Infrastructure.Data;
|
||||||
using TrueCV.Infrastructure.Identity;
|
using TrueCV.Infrastructure.Identity;
|
||||||
@@ -167,6 +169,12 @@ try
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule recurring job to reset monthly usage
|
||||||
|
RecurringJob.AddOrUpdate<TrueCV.Infrastructure.Jobs.ResetMonthlyUsageJob>(
|
||||||
|
"reset-monthly-usage",
|
||||||
|
job => job.ExecuteAsync(CancellationToken.None),
|
||||||
|
Cron.Daily(0, 5)); // Run at 00:05 UTC daily
|
||||||
|
|
||||||
// Login endpoint
|
// Login endpoint
|
||||||
app.MapPost("/account/perform-login", async (
|
app.MapPost("/account/perform-login", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@@ -215,6 +223,119 @@ try
|
|||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
// Stripe webhook endpoint (must be anonymous - called by Stripe)
|
||||||
|
app.MapPost("/api/stripe/webhook", async (
|
||||||
|
HttpContext context,
|
||||||
|
IStripeService stripeService) =>
|
||||||
|
{
|
||||||
|
var json = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||||
|
var signature = context.Request.Headers["Stripe-Signature"].FirstOrDefault();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(signature))
|
||||||
|
{
|
||||||
|
Log.Warning("Stripe webhook received without signature");
|
||||||
|
return Results.BadRequest("Missing Stripe-Signature header");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeService.HandleWebhookAsync(json, signature);
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Error processing Stripe webhook");
|
||||||
|
return Results.BadRequest("Webhook processing failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create checkout session endpoint
|
||||||
|
app.MapPost("/api/billing/create-checkout", async (
|
||||||
|
HttpContext context,
|
||||||
|
IStripeService stripeService,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IUserContextService userContext) =>
|
||||||
|
{
|
||||||
|
var userId = await userContext.GetCurrentUserIdAsync();
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await userManager.FindByIdAsync(userId.Value.ToString());
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Results.NotFound("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = await context.Request.ReadFormAsync();
|
||||||
|
var planString = form["plan"].ToString();
|
||||||
|
|
||||||
|
if (!Enum.TryParse<UserPlan>(planString, out var targetPlan) ||
|
||||||
|
targetPlan == UserPlan.Free)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Invalid plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||||
|
var successUrl = $"{baseUrl}/checkout-success";
|
||||||
|
var cancelUrl = $"{baseUrl}/pricing";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var checkoutUrl = await stripeService.CreateCheckoutSessionAsync(
|
||||||
|
userId.Value,
|
||||||
|
user.Email!,
|
||||||
|
targetPlan,
|
||||||
|
successUrl,
|
||||||
|
cancelUrl);
|
||||||
|
|
||||||
|
return Results.Redirect(checkoutUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create checkout session for user {UserId}", userId);
|
||||||
|
return Results.Redirect("/pricing?error=checkout_failed");
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
// Customer portal endpoint
|
||||||
|
app.MapPost("/api/billing/portal", async (
|
||||||
|
HttpContext context,
|
||||||
|
IStripeService stripeService,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IUserContextService userContext) =>
|
||||||
|
{
|
||||||
|
var userId = await userContext.GetCurrentUserIdAsync();
|
||||||
|
if (!userId.HasValue)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await userManager.FindByIdAsync(userId.Value.ToString());
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest("No billing account found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||||
|
var returnUrl = $"{baseUrl}/account/billing";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var portalUrl = await stripeService.CreateCustomerPortalSessionAsync(
|
||||||
|
user.StripeCustomerId,
|
||||||
|
returnUrl);
|
||||||
|
|
||||||
|
return Results.Redirect(portalUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create portal session for user {UserId}", userId);
|
||||||
|
return Results.Redirect("/account/billing?error=portal_failed");
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
"Anthropic": {
|
"Anthropic": {
|
||||||
"ApiKey": ""
|
"ApiKey": ""
|
||||||
},
|
},
|
||||||
|
"Stripe": {
|
||||||
|
"SecretKey": "",
|
||||||
|
"PublishableKey": "",
|
||||||
|
"WebhookSecret": "",
|
||||||
|
"PriceIds": {
|
||||||
|
"Professional": "",
|
||||||
|
"Enterprise": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"DefaultAdmin": {
|
"DefaultAdmin": {
|
||||||
"Email": "",
|
"Email": "",
|
||||||
"Password": ""
|
"Password": ""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class CVCheckServiceTests : IDisposable
|
|||||||
private readonly Mock<IFileStorageService> _fileStorageServiceMock;
|
private readonly Mock<IFileStorageService> _fileStorageServiceMock;
|
||||||
private readonly Mock<IBackgroundJobClient> _backgroundJobClientMock;
|
private readonly Mock<IBackgroundJobClient> _backgroundJobClientMock;
|
||||||
private readonly Mock<IAuditService> _auditServiceMock;
|
private readonly Mock<IAuditService> _auditServiceMock;
|
||||||
|
private readonly Mock<ISubscriptionService> _subscriptionServiceMock;
|
||||||
private readonly Mock<ILogger<CVCheckService>> _loggerMock;
|
private readonly Mock<ILogger<CVCheckService>> _loggerMock;
|
||||||
private readonly CVCheckService _sut;
|
private readonly CVCheckService _sut;
|
||||||
|
|
||||||
@@ -37,13 +38,19 @@ public sealed class CVCheckServiceTests : IDisposable
|
|||||||
_fileStorageServiceMock = new Mock<IFileStorageService>();
|
_fileStorageServiceMock = new Mock<IFileStorageService>();
|
||||||
_backgroundJobClientMock = new Mock<IBackgroundJobClient>();
|
_backgroundJobClientMock = new Mock<IBackgroundJobClient>();
|
||||||
_auditServiceMock = new Mock<IAuditService>();
|
_auditServiceMock = new Mock<IAuditService>();
|
||||||
|
_subscriptionServiceMock = new Mock<ISubscriptionService>();
|
||||||
_loggerMock = new Mock<ILogger<CVCheckService>>();
|
_loggerMock = new Mock<ILogger<CVCheckService>>();
|
||||||
|
|
||||||
|
// Setup subscription service to allow checks by default
|
||||||
|
_subscriptionServiceMock.Setup(x => x.CanPerformCheckAsync(It.IsAny<Guid>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
_sut = new CVCheckService(
|
_sut = new CVCheckService(
|
||||||
_dbContext,
|
_dbContext,
|
||||||
_fileStorageServiceMock.Object,
|
_fileStorageServiceMock.Object,
|
||||||
_backgroundJobClientMock.Object,
|
_backgroundJobClientMock.Object,
|
||||||
_auditServiceMock.Object,
|
_auditServiceMock.Object,
|
||||||
|
_subscriptionServiceMock.Object,
|
||||||
_loggerMock.Object);
|
_loggerMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user