diff --git a/src/TrueCV.Application/DTOs/SubscriptionInfoDto.cs b/src/TrueCV.Application/DTOs/SubscriptionInfoDto.cs new file mode 100644 index 0000000..32e0c80 --- /dev/null +++ b/src/TrueCV.Application/DTOs/SubscriptionInfoDto.cs @@ -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; +} diff --git a/src/TrueCV.Application/Interfaces/IStripeService.cs b/src/TrueCV.Application/Interfaces/IStripeService.cs new file mode 100644 index 0000000..a9a02b6 --- /dev/null +++ b/src/TrueCV.Application/Interfaces/IStripeService.cs @@ -0,0 +1,10 @@ +using TrueCV.Domain.Enums; + +namespace TrueCV.Application.Interfaces; + +public interface IStripeService +{ + Task CreateCheckoutSessionAsync(Guid userId, string email, UserPlan targetPlan, string successUrl, string cancelUrl); + Task CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl); + Task HandleWebhookAsync(string json, string signature); +} diff --git a/src/TrueCV.Application/Interfaces/ISubscriptionService.cs b/src/TrueCV.Application/Interfaces/ISubscriptionService.cs new file mode 100644 index 0000000..18e769d --- /dev/null +++ b/src/TrueCV.Application/Interfaces/ISubscriptionService.cs @@ -0,0 +1,11 @@ +using TrueCV.Application.DTOs; + +namespace TrueCV.Application.Interfaces; + +public interface ISubscriptionService +{ + Task CanPerformCheckAsync(Guid userId); + Task IncrementUsageAsync(Guid userId); + Task ResetUsageAsync(Guid userId); + Task GetSubscriptionInfoAsync(Guid userId); +} diff --git a/src/TrueCV.Domain/Constants/PlanLimits.cs b/src/TrueCV.Domain/Constants/PlanLimits.cs new file mode 100644 index 0000000..4fcf0eb --- /dev/null +++ b/src/TrueCV.Domain/Constants/PlanLimits.cs @@ -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; +} diff --git a/src/TrueCV.Domain/Exceptions/QuotaExceededException.cs b/src/TrueCV.Domain/Exceptions/QuotaExceededException.cs new file mode 100644 index 0000000..efba9cb --- /dev/null +++ b/src/TrueCV.Domain/Exceptions/QuotaExceededException.cs @@ -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) + { + } +} diff --git a/src/TrueCV.Infrastructure/Configuration/StripeSettings.cs b/src/TrueCV.Infrastructure/Configuration/StripeSettings.cs new file mode 100644 index 0000000..4ae985c --- /dev/null +++ b/src/TrueCV.Infrastructure/Configuration/StripeSettings.cs @@ -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; +} diff --git a/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs b/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs index 2c3230e..cfcdd75 100644 --- a/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs @@ -40,6 +40,18 @@ public class ApplicationDbContext : IdentityDbContext u.StripeCustomerId) .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) .WithOne() .HasForeignKey(c => c.UserId) diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.Designer.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.Designer.cs new file mode 100644 index 0000000..5ec2ee5 --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.Designer.cs @@ -0,0 +1,519 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Details") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExtractedDataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ProcessingStage") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ReportJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CVCheckId") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ScoreImpact") + .HasColumnType("int"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("CompanyNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("AccountsCategory") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CachedAt") + .HasColumnType("datetime2"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("CompanyType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DissolutionDate") + .HasColumnType("date"); + + b.Property("IncorporationDate") + .HasColumnType("date"); + + b.Property("SicCodesJson") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("CompanyNumber"); + + b.ToTable("CompanyCache"); + }); + + modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ChecksUsedThisMonth") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CurrentPeriodEnd") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeCustomerId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SubscriptionStatus") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("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", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", 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", 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 + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.cs new file mode 100644 index 0000000..cbffc71 --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260121115517_AddStripeSubscriptionFields.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrueCV.Infrastructure.Data.Migrations +{ + /// + public partial class AddStripeSubscriptionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CurrentPeriodEnd", + table: "AspNetUsers", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "StripeSubscriptionId", + table: "AspNetUsers", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + 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"); + } + + /// + 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"); + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 47c6c2d..ef6fce3 100644 --- a/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -351,6 +351,9 @@ namespace TrueCV.Infrastructure.Data.Migrations .IsConcurrencyToken() .HasColumnType("nvarchar(max)"); + b.Property("CurrentPeriodEnd") + .HasColumnType("datetime2"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -393,6 +396,14 @@ namespace TrueCV.Infrastructure.Data.Migrations .HasMaxLength(256) .HasColumnType("nvarchar(256)"); + b.Property("StripeSubscriptionId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SubscriptionStatus") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + b.Property("TwoFactorEnabled") .HasColumnType("bit"); @@ -410,6 +421,12 @@ namespace TrueCV.Infrastructure.Data.Migrations .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); }); diff --git a/src/TrueCV.Infrastructure/DependencyInjection.cs b/src/TrueCV.Infrastructure/DependencyInjection.cs index 3eda6d0..76e5974 100644 --- a/src/TrueCV.Infrastructure/DependencyInjection.cs +++ b/src/TrueCV.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Polly; using Polly.Extensions.Http; +using Stripe; using TrueCV.Application.Interfaces; using TrueCV.Infrastructure.Configuration; using TrueCV.Infrastructure.Data; @@ -74,6 +75,16 @@ public static class DependencyInjection services.Configure( configuration.GetSection(LocalStorageSettings.SectionName)); + services.Configure( + configuration.GetSection(StripeSettings.SectionName)); + + // Configure Stripe API key + var stripeSettings = configuration.GetSection(StripeSettings.SectionName).Get(); + if (!string.IsNullOrEmpty(stripeSettings?.SecretKey)) + { + StripeConfiguration.ApiKey = stripeSettings.SecretKey; + } + // Configure HttpClient for CompaniesHouseClient with retry policy services.AddHttpClient((serviceProvider, client) => { @@ -97,6 +108,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Register file storage - use local storage if configured, otherwise Azure var useLocalStorage = configuration.GetValue("UseLocalStorage"); @@ -111,6 +124,7 @@ public static class DependencyInjection // Register Hangfire jobs services.AddTransient(); + services.AddTransient(); return services; } diff --git a/src/TrueCV.Infrastructure/Identity/ApplicationUser.cs b/src/TrueCV.Infrastructure/Identity/ApplicationUser.cs index 2a3a2ca..cd0e175 100644 --- a/src/TrueCV.Infrastructure/Identity/ApplicationUser.cs +++ b/src/TrueCV.Infrastructure/Identity/ApplicationUser.cs @@ -10,6 +10,12 @@ public class ApplicationUser : IdentityUser 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 ICollection CVChecks { get; set; } = new List(); diff --git a/src/TrueCV.Infrastructure/Jobs/ResetMonthlyUsageJob.cs b/src/TrueCV.Infrastructure/Jobs/ResetMonthlyUsageJob.cs new file mode 100644 index 0000000..da370d4 --- /dev/null +++ b/src/TrueCV.Infrastructure/Jobs/ResetMonthlyUsageJob.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TrueCV.Domain.Enums; +using TrueCV.Infrastructure.Data; + +namespace TrueCV.Infrastructure.Jobs; + +/// +/// 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. +/// +public sealed class ResetMonthlyUsageJob +{ + private readonly ApplicationDbContext _dbContext; + private readonly ILogger _logger; + + public ResetMonthlyUsageJob( + ApplicationDbContext dbContext, + ILogger 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; + } + } +} diff --git a/src/TrueCV.Infrastructure/Services/CVCheckService.cs b/src/TrueCV.Infrastructure/Services/CVCheckService.cs index d99d1bc..52dbd48 100644 --- a/src/TrueCV.Infrastructure/Services/CVCheckService.cs +++ b/src/TrueCV.Infrastructure/Services/CVCheckService.cs @@ -8,6 +8,7 @@ using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Domain.Entities; using TrueCV.Domain.Enums; +using TrueCV.Domain.Exceptions; using TrueCV.Infrastructure.Data; using TrueCV.Infrastructure.Jobs; @@ -19,6 +20,7 @@ public sealed class CVCheckService : ICVCheckService private readonly IFileStorageService _fileStorageService; private readonly IBackgroundJobClient _backgroundJobClient; private readonly IAuditService _auditService; + private readonly ISubscriptionService _subscriptionService; private readonly ILogger _logger; public CVCheckService( @@ -26,12 +28,14 @@ public sealed class CVCheckService : ICVCheckService IFileStorageService fileStorageService, IBackgroundJobClient backgroundJobClient, IAuditService auditService, + ISubscriptionService subscriptionService, ILogger logger) { _dbContext = dbContext; _fileStorageService = fileStorageService; _backgroundJobClient = backgroundJobClient; _auditService = auditService; + _subscriptionService = subscriptionService; _logger = logger; } @@ -42,6 +46,13 @@ public sealed class CVCheckService : ICVCheckService _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 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}"); + // Increment usage after successful creation + await _subscriptionService.IncrementUsageAsync(userId); + return cvCheck.Id; } diff --git a/src/TrueCV.Infrastructure/Services/StripeService.cs b/src/TrueCV.Infrastructure/Services/StripeService.cs new file mode 100644 index 0000000..ba598a2 --- /dev/null +++ b/src/TrueCV.Infrastructure/Services/StripeService.cs @@ -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 _logger; + + public StripeService( + ApplicationDbContext dbContext, + IOptions settings, + ILogger logger) + { + _dbContext = dbContext; + _settings = settings.Value; + _logger = logger; + + StripeConfiguration.ApiKey = _settings.SecretKey; + } + + public async Task 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 + { + new() + { + Price = priceId, + Quantity = 1 + } + }, + SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}", + CancelUrl = cancelUrl, + Metadata = new Dictionary + { + { "user_id", userId.ToString() }, + { "target_plan", targetPlan.ToString() } + }, + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + { "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 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(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(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); + } +} diff --git a/src/TrueCV.Infrastructure/Services/SubscriptionService.cs b/src/TrueCV.Infrastructure/Services/SubscriptionService.cs new file mode 100644 index 0000000..60c8ac2 --- /dev/null +++ b/src/TrueCV.Infrastructure/Services/SubscriptionService.cs @@ -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 _logger; + + public SubscriptionService( + ApplicationDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task 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 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) + }; + } +} diff --git a/src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj b/src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj index 190b72e..83cfa28 100644 --- a/src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj +++ b/src/TrueCV.Infrastructure/TrueCV.Infrastructure.csproj @@ -19,6 +19,7 @@ + diff --git a/src/TrueCV.Web/Components/Pages/Account/Billing.razor b/src/TrueCV.Web/Components/Pages/Account/Billing.razor new file mode 100644 index 0000000..8acd121 --- /dev/null +++ b/src/TrueCV.Web/Components/Pages/Account/Billing.razor @@ -0,0 +1,220 @@ +@page "/account/billing" +@attribute [Authorize] +@rendermode InteractiveServer + +@inject ISubscriptionService SubscriptionService +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject NavigationManager NavigationManager + +Billing - TrueCV + +
+
+
+
+

Billing & Subscription

+

Manage your subscription and view usage

+
+ + @if (!string.IsNullOrEmpty(_errorMessage)) + { + + } + + @if (_isLoading) + { +
+
+ Loading... +
+
+ } + else if (_subscription != null) + { + +
+
+
+
+
Current Plan
+

Your active subscription details

+
+ + @_subscription.Plan + +
+ +
+
+
+
Price
+
@_subscription.DisplayPrice
+
+
+
+
+
Status
+
+ @if (_subscription.HasActiveSubscription) + { + Active + } + else if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free) + { + Free Tier + } + else + { + @(_subscription.SubscriptionStatus ?? "Inactive") + } +
+
+
+
+ + @if (_subscription.CurrentPeriodEnd.HasValue) + { +
+ Next billing date: @_subscription.CurrentPeriodEnd.Value.ToString("dd MMMM yyyy") +
+ } + +
+ + @if (_subscription.Plan == TrueCV.Domain.Enums.UserPlan.Free) + { + Upgrade Plan + } + else + { + Change Plan + } + + @if (_subscription.HasActiveSubscription) + { +
+ +
+ } +
+
+
+ + +
+
+
Usage This Month
+ +
+
+ CV Checks + + @if (_subscription.IsUnlimited) + { + @_subscription.ChecksUsedThisMonth used (Unlimited) + } + else + { + @_subscription.ChecksUsedThisMonth / @_subscription.MonthlyLimit + } + +
+ @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"; + +
+
+
+ + @if (_subscription.ChecksRemaining <= 0) + { +
+ + You've used all your checks this month. + Upgrade your plan for more. + +
+ } + else if (_subscription.ChecksRemaining <= 3 && _subscription.Plan != TrueCV.Domain.Enums.UserPlan.Free) + { +
+ + You have @_subscription.ChecksRemaining checks remaining this month. + +
+ } + } +
+
+
+ + + @if (_subscription.HasActiveSubscription) + { +
+
+
Billing Management
+

+ Use the Stripe Customer Portal to update your payment method, view invoices, or cancel your subscription. +

+
+ +
+
+
+ } + } +
+
+
+ +@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; + } + } +} diff --git a/src/TrueCV.Web/Components/Pages/Account/Settings.razor b/src/TrueCV.Web/Components/Pages/Account/Settings.razor new file mode 100644 index 0000000..ca3571e --- /dev/null +++ b/src/TrueCV.Web/Components/Pages/Account/Settings.razor @@ -0,0 +1,235 @@ +@page "/account/settings" +@attribute [Authorize] +@rendermode InteractiveServer + +@using Microsoft.AspNetCore.Identity +@using TrueCV.Infrastructure.Identity + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject NavigationManager NavigationManager +@inject ILogger Logger + +Account Settings - TrueCV + +
+
+
+
+

Account Settings

+

Manage your account details and security

+
+ + @if (!string.IsNullOrEmpty(_successMessage)) + { + + } + + @if (!string.IsNullOrEmpty(_errorMessage)) + { + + } + + +
+
+
Profile Information
+ +
+ + + Email cannot be changed +
+ +
+ +
+ @_userPlan + Manage +
+
+ +
+ +

@_memberSince?.ToString("dd MMMM yyyy")

+
+
+
+ + +
+
+
Change Password
+ + + + +
+ + + +
+ +
+ + + + Minimum 12 characters with uppercase, lowercase, number, and special character +
+ +
+ + + +
+ + +
+
+
+ + + +
+
+
+ +@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; } = ""; + } +} diff --git a/src/TrueCV.Web/Components/Pages/Check.razor b/src/TrueCV.Web/Components/Pages/Check.razor index 14c47c9..80001a2 100644 --- a/src/TrueCV.Web/Components/Pages/Check.razor +++ b/src/TrueCV.Web/Components/Pages/Check.razor @@ -3,6 +3,7 @@ @rendermode InteractiveServer @inject ICVCheckService CVCheckService +@inject ISubscriptionService SubscriptionService @inject NavigationManager NavigationManager @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILogger Logger @@ -21,9 +22,47 @@ For UK employment history + + @if (_subscription != null) + { +
+ @if (_subscription.IsUnlimited) + { + + + + + + + Unlimited checks + + } + else + { + + @_subscription.ChecksRemaining of @_subscription.MonthlyLimit checks remaining + + } +
+ } - @if (!string.IsNullOrEmpty(_errorMessage)) + @if (_quotaExceeded) + { + + } + else if (!string.IsNullOrEmpty(_errorMessage)) { + +@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(); + } + } + } +} diff --git a/src/TrueCV.Web/Program.cs b/src/TrueCV.Web/Program.cs index a55b967..7ff6533 100644 --- a/src/TrueCV.Web/Program.cs +++ b/src/TrueCV.Web/Program.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Serilog; +using TrueCV.Application.Interfaces; +using TrueCV.Domain.Enums; using TrueCV.Infrastructure; using TrueCV.Infrastructure.Data; using TrueCV.Infrastructure.Identity; @@ -167,6 +169,12 @@ try }); } + // Schedule recurring job to reset monthly usage + RecurringJob.AddOrUpdate( + "reset-monthly-usage", + job => job.ExecuteAsync(CancellationToken.None), + Cron.Daily(0, 5)); // Run at 00:05 UTC daily + // Login endpoint app.MapPost("/account/perform-login", async ( HttpContext context, @@ -215,6 +223,119 @@ try // Health check endpoint 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 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(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 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() .AddInteractiveServerRenderMode(); diff --git a/src/TrueCV.Web/appsettings.json b/src/TrueCV.Web/appsettings.json index 731490e..e9f8398 100644 --- a/src/TrueCV.Web/appsettings.json +++ b/src/TrueCV.Web/appsettings.json @@ -10,6 +10,15 @@ "Anthropic": { "ApiKey": "" }, + "Stripe": { + "SecretKey": "", + "PublishableKey": "", + "WebhookSecret": "", + "PriceIds": { + "Professional": "", + "Enterprise": "" + } + }, "DefaultAdmin": { "Email": "", "Password": "" diff --git a/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs index 0ae8818..7cb61d3 100644 --- a/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs +++ b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs @@ -24,6 +24,7 @@ public sealed class CVCheckServiceTests : IDisposable private readonly Mock _fileStorageServiceMock; private readonly Mock _backgroundJobClientMock; private readonly Mock _auditServiceMock; + private readonly Mock _subscriptionServiceMock; private readonly Mock> _loggerMock; private readonly CVCheckService _sut; @@ -37,13 +38,19 @@ public sealed class CVCheckServiceTests : IDisposable _fileStorageServiceMock = new Mock(); _backgroundJobClientMock = new Mock(); _auditServiceMock = new Mock(); + _subscriptionServiceMock = new Mock(); _loggerMock = new Mock>(); + // Setup subscription service to allow checks by default + _subscriptionServiceMock.Setup(x => x.CanPerformCheckAsync(It.IsAny())) + .ReturnsAsync(true); + _sut = new CVCheckService( _dbContext, _fileStorageServiceMock.Object, _backgroundJobClientMock.Object, _auditServiceMock.Object, + _subscriptionServiceMock.Object, _loggerMock.Object); }