diff --git a/.gitignore b/.gitignore index 3331de69..0907882d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ Desktop.ini ## Docker / Caddy caddy_data/ -caddy_config/ \ No newline at end of file +caddy_config/.claude/worktrees/ diff --git a/src/Orbit.Api/Controllers/SubscriptionController.cs b/src/Orbit.Api/Controllers/SubscriptionController.cs index 24ed575f..6bcf5d7a 100644 --- a/src/Orbit.Api/Controllers/SubscriptionController.cs +++ b/src/Orbit.Api/Controllers/SubscriptionController.cs @@ -46,7 +46,7 @@ public async Task CreateCheckout( CancellationToken ct) { var userId = HttpContext.GetUserId(); - var user = await userRepository.GetByIdAsync(userId, ct); + var user = await userRepository.FindOneTrackedAsync(u => u.Id == userId, cancellationToken: ct); if (user is null) return NotFound(new { error = ErrorMessages.UserNotFound }); // Prefer X-Forwarded-For (set by BFF/reverse proxy) over direct connection IP @@ -85,8 +85,7 @@ public async Task CreateCheckout( await unitOfWork.SaveChangesAsync(ct); } - var sessionService = new SessionService(); - var session = await sessionService.CreateAsync(new SessionCreateOptions + var sessionOptions = new SessionCreateOptions { Customer = user.StripeCustomerId, Mode = "subscription", @@ -94,7 +93,17 @@ public async Task CreateCheckout( SuccessUrl = _settings.SuccessUrl, CancelUrl = _settings.CancelUrl, Metadata = new Dictionary { { "userId", userId.ToString() } } - }, cancellationToken: ct); + }; + + // Apply referral discount coupon if user has one + if (!string.IsNullOrEmpty(user.ReferralCouponId)) + { + sessionOptions.Discounts = [new SessionDiscountOptions { PromotionCode = user.ReferralCouponId }]; + logger.LogInformation("Applying referral coupon {CouponId} to checkout for user {UserId}", user.ReferralCouponId, userId); + } + + var sessionService = new SessionService(); + var session = await sessionService.CreateAsync(sessionOptions, cancellationToken: ct); logger.LogInformation("Checkout created for user {UserId} price={PriceId} country={Country}", userId, priceId, countryCode); return Ok(new CheckoutResponse(session.Url)); @@ -199,6 +208,14 @@ public async Task HandleWebhook(CancellationToken ct) user.SetStripeCustomerId(session.CustomerId ?? session.Customer?.Id ?? ""); user.SetStripeSubscription(subscriptionId, periodEnd, GetSubscriptionInterval(subscription)); + + // Clear referral coupon after successful checkout (mark as redeemed) + if (!string.IsNullOrEmpty(user.ReferralCouponId)) + { + logger.LogInformation("Clearing referral coupon {CouponId} for user {UserId} after checkout", user.ReferralCouponId, uid); + user.SetReferralCoupon(null); + } + await unitOfWork.SaveChangesAsync(ct); logger.LogInformation("User {UserId} upgraded to Pro, expires {Expires}", uid, periodEnd); } diff --git a/src/Orbit.Api/Program.cs b/src/Orbit.Api/Program.cs index 487b4714..f9694e2b 100644 --- a/src/Orbit.Api/Program.cs +++ b/src/Orbit.Api/Program.cs @@ -19,6 +19,11 @@ var builder = WebApplication.CreateBuilder(args); +// --- Encryption --- +builder.Services.Configure( + builder.Configuration.GetSection(EncryptionSettings.SectionName)); +builder.Services.AddSingleton(); + // --- Database --- builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); @@ -67,12 +72,13 @@ builder.Services.Configure( builder.Configuration.GetSection(VapidSettings.SectionName)); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHttpClient(); // Initialize Firebase Admin SDK for FCM diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index bfb5fb95..bb6a6df1 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -33,6 +33,9 @@ "PrivateKey": "REPLACE-IN-DEVELOPMENT-JSON", "Subject": "mailto:hello@useorbit.org" }, + "Encryption": { + "Key": "REPLACE-IN-DEVELOPMENT-JSON" + }, "Google": { "ClientId": "", "ClientSecret": "" diff --git a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs index 916f98a2..8728f495 100644 --- a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs +++ b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs @@ -51,7 +51,7 @@ public async Task> Handle(GoogleAuthCommand request, Cance // Find or create user (tracked so token updates persist) var user = await userRepository.FindOneTrackedAsync( - u => u.Email.ToLower() == email.ToLower(), + u => u.Email == email, cancellationToken: cancellationToken); var isNewUser = user is null; diff --git a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs index fc76eaf5..b6cd6cdf 100644 --- a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs +++ b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs @@ -55,7 +55,7 @@ public async Task> Handle(VerifyCodeCommand request, Cance // Find or create user (tracked so deactivation cancellation persists) var user = await userRepository.FindOneTrackedAsync( - u => u.Email.ToLower() == email, + u => u.Email == email, cancellationToken: cancellationToken); var isNewUser = user is null; diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 5cc2b570..207acab6 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -15,7 +15,7 @@ public static class AppConstants public const int MaxBulkOperationSize = 100; public const int MaxGoalsPerHabit = 10; public const int MaxHabitsPerGoal = 20; - public const int DefaultReferralRewardDays = 10; + public const int ReferralDiscountPercent = 10; public const int DefaultMaxReferrals = 10; public const int ReferralCompletionThreshold = 3; public const int ReferralCompletionWindowDays = 7; diff --git a/src/Orbit.Application/Referrals/Commands/CheckReferralCompletionCommand.cs b/src/Orbit.Application/Referrals/Commands/CheckReferralCompletionCommand.cs index db807f41..bb8babf8 100644 --- a/src/Orbit.Application/Referrals/Commands/CheckReferralCompletionCommand.cs +++ b/src/Orbit.Application/Referrals/Commands/CheckReferralCompletionCommand.cs @@ -15,9 +15,8 @@ public class CheckReferralCompletionCommandHandler( IGenericRepository habitRepository, IGenericRepository habitLogRepository, IGenericRepository notificationRepository, - IAppConfigService appConfigService, IPushNotificationService pushNotificationService, - ISubscriptionRewardService subscriptionRewardService, + IReferralRewardService referralRewardService, IUnitOfWork unitOfWork) : IRequestHandler { public async Task Handle(CheckReferralCompletionCommand request, CancellationToken cancellationToken) @@ -71,27 +70,17 @@ public async Task Handle(CheckReferralCompletionCommand request, Cancell // Mark referral as completed trackedReferral.MarkCompleted(); - // Grant reward to referrer - var rewardDays = await appConfigService.GetAsync( - "ReferralRewardDays", AppConstants.DefaultReferralRewardDays, cancellationToken); - + // Grant discount coupon to referrer var referrer = await userRepository.FindOneTrackedAsync( u => u.Id == trackedReferral.ReferrerId, cancellationToken: cancellationToken); if (referrer is not null) { - if (referrer.IsPro && !string.IsNullOrEmpty(referrer.StripeSubscriptionId)) - { - // Pro user: extend Stripe subscription trial_end to delay next charge - await subscriptionRewardService.ExtendSubscriptionAsync( - referrer.StripeSubscriptionId, rewardDays, cancellationToken); - } - else - { - // Free/Trial user: extend trial period - referrer.ExtendTrial(rewardDays); - } + // Create a 10% discount coupon for the referrer (service handles Stripe customer creation) + var promoCodeId = await referralRewardService.CreateReferralCouponAsync( + referrer.Id, cancellationToken); + referrer.SetReferralCoupon(promoCodeId); } trackedReferral.MarkRewarded(); @@ -103,7 +92,7 @@ await subscriptionRewardService.ExtendSubscriptionAsync( var notification = Notification.Create( referrer.Id, "Referral Completed!", - $"Your friend joined Orbit and you earned {rewardDays} extra days of Pro!", + "Your friend joined Orbit and you earned a 10% discount coupon for Pro!", "/profile"); await notificationRepository.AddAsync(notification, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); @@ -115,7 +104,7 @@ await subscriptionRewardService.ExtendSubscriptionAsync( await pushNotificationService.SendToUserAsync( referrer.Id, "Referral Completed!", - $"You earned {rewardDays} extra days of Pro!", + "You earned a 10% discount coupon for Pro!", "/profile", CancellationToken.None); } diff --git a/src/Orbit.Application/Referrals/Commands/ProcessReferralCodeCommand.cs b/src/Orbit.Application/Referrals/Commands/ProcessReferralCodeCommand.cs index b5c56cf3..2170d7ad 100644 --- a/src/Orbit.Application/Referrals/Commands/ProcessReferralCodeCommand.cs +++ b/src/Orbit.Application/Referrals/Commands/ProcessReferralCodeCommand.cs @@ -13,6 +13,7 @@ public class ProcessReferralCodeCommandHandler( IGenericRepository userRepository, IGenericRepository referralRepository, IAppConfigService appConfigService, + IReferralRewardService referralRewardService, IUnitOfWork unitOfWork) : IRequestHandler { public async Task Handle(ProcessReferralCodeCommand request, CancellationToken cancellationToken) @@ -51,9 +52,10 @@ public async Task Handle(ProcessReferralCodeCommand request, Cancellatio var referral = Referral.Create(referrer.Id, newUser.Id); await referralRepository.AddAsync(referral, cancellationToken); - // Give referred user bonus trial days on top of the default 7-day trial - var rewardDays = await appConfigService.GetAsync("ReferralRewardDays", AppConstants.DefaultReferralRewardDays, cancellationToken); - newUser.ExtendTrial(rewardDays); + // Create a 10% discount coupon for the referred user + var promoCodeId = await referralRewardService.CreateReferralCouponAsync( + newUser.Id, cancellationToken); + newUser.SetReferralCoupon(promoCodeId); await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/Referrals/Queries/GetReferralStatsQuery.cs b/src/Orbit.Application/Referrals/Queries/GetReferralStatsQuery.cs index 7a402477..9d231c15 100644 --- a/src/Orbit.Application/Referrals/Queries/GetReferralStatsQuery.cs +++ b/src/Orbit.Application/Referrals/Queries/GetReferralStatsQuery.cs @@ -13,7 +13,8 @@ public record ReferralStatsResponse( int SuccessfulReferrals, int PendingReferrals, int MaxReferrals, - int RewardDays); + string RewardType, + int DiscountPercent); public record GetReferralStatsQuery(Guid UserId) : IRequest>; @@ -37,8 +38,6 @@ public async Task> Handle(GetReferralStatsQuery re var maxReferrals = await appConfigService.GetAsync( "MaxReferrals", AppConstants.DefaultMaxReferrals, cancellationToken); - var rewardDays = await appConfigService.GetAsync( - "ReferralRewardDays", AppConstants.DefaultReferralRewardDays, cancellationToken); var referralLink = user.ReferralCode is not null ? $"https://app.useorbit.org/r/{user.ReferralCode}" @@ -50,6 +49,7 @@ public async Task> Handle(GetReferralStatsQuery re successful, pending, maxReferrals, - rewardDays)); + "discount", + AppConstants.ReferralDiscountPercent)); } } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index eb87aa78..9075fdd3 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -41,6 +41,7 @@ public class User : Entity public Guid? ReferredByUserId { get; private set; } public int TotalXp { get; private set; } = 0; public int Level { get; private set; } = 1; + public string? ReferralCouponId { get; private set; } [NotMapped] public bool IsPro => IsLifetimePro || (Plan == UserPlan.Pro && PlanExpiresAt.HasValue && PlanExpiresAt.Value > DateTime.UtcNow); @@ -199,6 +200,8 @@ public void ExtendTrial(int days) TrialEndsAt = TrialEndsAt.Value.AddDays(days); } + public void SetReferralCoupon(string? couponId) => ReferralCouponId = couponId; + public void AddXp(int amount) { if (amount <= 0) return; diff --git a/src/Orbit.Domain/Interfaces/IEncryptionService.cs b/src/Orbit.Domain/Interfaces/IEncryptionService.cs new file mode 100644 index 00000000..3d4dff49 --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IEncryptionService.cs @@ -0,0 +1,9 @@ +namespace Orbit.Domain.Interfaces; + +public interface IEncryptionService +{ + string Encrypt(string plaintext); + string Decrypt(string ciphertext); + string? EncryptNullable(string? plaintext); + string? DecryptNullable(string? ciphertext); +} diff --git a/src/Orbit.Domain/Interfaces/IReferralRewardService.cs b/src/Orbit.Domain/Interfaces/IReferralRewardService.cs new file mode 100644 index 00000000..a9caa43b --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IReferralRewardService.cs @@ -0,0 +1,15 @@ +namespace Orbit.Domain.Interfaces; + +public interface IReferralRewardService +{ + /// + /// Ensures the user has a Stripe customer, creates a one-time 10% discount coupon, + /// and returns the Stripe promotion code ID. + /// + Task CreateReferralCouponAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// Retrieves the user's stored referral promotion code ID, or null if none exists. + /// + Task GetUserPromotionCodeAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Domain/Interfaces/ISubscriptionRewardService.cs b/src/Orbit.Domain/Interfaces/ISubscriptionRewardService.cs deleted file mode 100644 index 634ad4a7..00000000 --- a/src/Orbit.Domain/Interfaces/ISubscriptionRewardService.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Orbit.Domain.Interfaces; - -public interface ISubscriptionRewardService -{ - /// - /// Extends a Pro user's Stripe subscription by delaying the next charge. - /// Uses Stripe's trial_end to grant free days without breaking the billing cycle. - /// - Task ExtendSubscriptionAsync(string subscriptionId, int days, CancellationToken cancellationToken = default); -} diff --git a/src/Orbit.Infrastructure/Configuration/EncryptionSettings.cs b/src/Orbit.Infrastructure/Configuration/EncryptionSettings.cs new file mode 100644 index 00000000..a8625e77 --- /dev/null +++ b/src/Orbit.Infrastructure/Configuration/EncryptionSettings.cs @@ -0,0 +1,7 @@ +namespace Orbit.Infrastructure.Configuration; + +public sealed class EncryptionSettings +{ + public const string SectionName = "Encryption"; + public required string Key { get; init; } // Base64-encoded 256-bit AES key +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index d48fe4ad..55f8e0f4 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -544,6 +544,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReferralCode") .HasColumnType("text"); + b.Property("ReferralCouponId") + .HasColumnType("text"); + b.Property("ReferredByUserId") .HasColumnType("uuid"); diff --git a/src/Orbit.Infrastructure/Persistence/EncryptionValueConverter.cs b/src/Orbit.Infrastructure/Persistence/EncryptionValueConverter.cs new file mode 100644 index 00000000..c5f47085 --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/EncryptionValueConverter.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Persistence; + +/// +/// EF Core ValueConverter that encrypts string values on write and decrypts on read. +/// For non-nullable string columns. +/// +public sealed class EncryptionValueConverter : ValueConverter +{ + public EncryptionValueConverter(IEncryptionService encryptionService) + : base( + v => encryptionService.Encrypt(v), + v => encryptionService.Decrypt(v)) + { + } +} + +/// +/// EF Core ValueConverter that encrypts nullable string values on write and decrypts on read. +/// Passes through null without encrypting. +/// +public sealed class NullableEncryptionValueConverter : ValueConverter +{ + public NullableEncryptionValueConverter(IEncryptionService encryptionService) + : base( + v => encryptionService.EncryptNullable(v), + v => encryptionService.DecryptNullable(v)) + { + } +} diff --git a/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.Designer.cs b/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.Designer.cs new file mode 100644 index 00000000..ea8ef01d --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.Designer.cs @@ -0,0 +1,724 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260328152918_ChangeReferralToDiscountCoupon")] + partial class ChangeReferralToDiscountCoupon + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("CompletedTours") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasDismissedMissions") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.cs b/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.cs new file mode 100644 index 00000000..532c6562 --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/Migrations/20260328152918_ChangeReferralToDiscountCoupon.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Persistence.Migrations +{ + /// + public partial class ChangeReferralToDiscountCoupon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReferralCouponId", + table: "Users", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReferralCouponId", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index fe695b29..4e6e2fba 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -2,12 +2,21 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Domain.ValueObjects; namespace Orbit.Infrastructure.Persistence; -public class OrbitDbContext(DbContextOptions options) : DbContext(options) +public class OrbitDbContext : DbContext { + private readonly IEncryptionService? _encryptionService; + + public OrbitDbContext(DbContextOptions options, IEncryptionService? encryptionService = null) + : base(options) + { + _encryptionService = encryptionService; + } + public DbSet Users => Set(); public DbSet Habits => Set(); public DbSet HabitLogs => Set(); @@ -25,10 +34,26 @@ public class OrbitDbContext(DbContextOptions options) : DbContex protected override void OnModelCreating(ModelBuilder modelBuilder) { + // --- Encryption Value Converters --- + EncryptionValueConverter? encConverter = null; + NullableEncryptionValueConverter? nullableEncConverter = null; + + if (_encryptionService is not null) + { + encConverter = new EncryptionValueConverter(_encryptionService); + nullableEncConverter = new NullableEncryptionValueConverter(_encryptionService); + } + modelBuilder.Entity(entity => { entity.HasIndex(u => u.Email).IsUnique(); entity.HasIndex(u => u.ReferralCode).IsUnique().HasFilter("\"ReferralCode\" IS NOT NULL"); + + if (nullableEncConverter is not null) + { + entity.Property(u => u.GoogleAccessToken).HasConversion(nullableEncConverter).HasColumnType("text"); + entity.Property(u => u.GoogleRefreshToken).HasConversion(nullableEncConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => @@ -78,17 +103,32 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) c => JsonSerializer.Serialize(c, (JsonSerializerOptions?)null).GetHashCode(), c => JsonSerializer.Deserialize>(JsonSerializer.Serialize(c, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); + if (encConverter is not null && nullableEncConverter is not null) + { + entity.Property(h => h.Title).HasConversion(encConverter).HasColumnType("text"); + entity.Property(h => h.Description).HasConversion(nullableEncConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => { entity.HasIndex(l => new { l.HabitId, l.Date }); + + if (nullableEncConverter is not null) + { + entity.Property(l => l.Note).HasConversion(nullableEncConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => { entity.HasIndex(f => new { f.UserId, f.IsDeleted }); entity.HasQueryFilter(f => !f.IsDeleted); + + if (encConverter is not null) + { + entity.Property(f => f.FactText).HasConversion(encConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => @@ -137,11 +177,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .UsingEntity("HabitGoals", l => l.HasOne(typeof(Habit)).WithMany().HasForeignKey("HabitId").OnDelete(DeleteBehavior.Cascade), r => r.HasOne(typeof(Goal)).WithMany().HasForeignKey("GoalId").OnDelete(DeleteBehavior.Cascade)); + + if (encConverter is not null && nullableEncConverter is not null) + { + entity.Property(g => g.Title).HasConversion(encConverter).HasColumnType("text"); + entity.Property(g => g.Description).HasConversion(nullableEncConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => { entity.HasIndex(l => l.GoalId); + + if (nullableEncConverter is not null) + { + entity.Property(l => l.Note).HasConversion(nullableEncConverter).HasColumnType("text"); + } }); modelBuilder.Entity(entity => diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContextFactory.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContextFactory.cs new file mode 100644 index 00000000..3d2b5086 --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContextFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace Orbit.Infrastructure.Persistence; + +/// +/// Design-time factory for OrbitDbContext. Used by EF Core tools (dotnet ef migrations add, etc.) +/// when the full application service provider is not available. +/// Creates the DbContext without IEncryptionService (encryption converters disabled at design time). +/// +public class OrbitDbContextFactory : IDesignTimeDbContextFactory +{ + public OrbitDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Orbit.Api")) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(configuration.GetConnectionString("DefaultConnection")); + + // No encryption service at design time -- converters won't be applied + return new OrbitDbContext(optionsBuilder.Options); + } +} diff --git a/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs b/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs new file mode 100644 index 00000000..ab8bb9fb --- /dev/null +++ b/src/Orbit.Infrastructure/Services/DataEncryptionMigrationService.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +/// +/// One-time startup service that encrypts all existing plaintext data. +/// Uses an AppConfig flag ("EncryptionMigrationComplete") to only run once. +/// Processes entities in small batches to avoid overwhelming the database. +/// Safe to run multiple times (idempotent). +/// +public sealed class DataEncryptionMigrationService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private const int BatchSize = 50; + private const string MigrationFlag = "EncryptionMigrationComplete"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + + try + { + if (await HasAlreadyRun(stoppingToken)) + { + logger.LogInformation("Encryption migration already completed -- skipping"); + return; + } + + logger.LogInformation("Starting full data encryption migration"); + + var success = true; + success &= await MigrateEntities("Habits", stoppingToken); + success &= await MigrateEntities("HabitLogs", stoppingToken); + success &= await MigrateUserFacts(stoppingToken); + success &= await MigrateEntities("Goals", stoppingToken); + success &= await MigrateEntities("GoalProgressLogs", stoppingToken); + + if (success) + { + await SetMigrationComplete(stoppingToken); + logger.LogInformation("Full data encryption migration completed successfully"); + } + else + { + logger.LogWarning("Encryption migration had errors -- will retry on next startup"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Data encryption migration failed -- will retry on next startup"); + } + } + + private async Task HasAlreadyRun(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.AppConfigs.AnyAsync(c => c.Key == MigrationFlag, stoppingToken); + } + + private async Task SetMigrationComplete(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.AppConfigs.Add(AppConfig.Create(MigrationFlag, "true", "Set automatically after first encryption migration")); + await db.SaveChangesAsync(stoppingToken); + } + + /// + /// Loads all entities of a type in batches, marks them as Modified, and saves. + /// The ValueConverter encrypts on write -- so loading (decrypt/passthrough) then saving + /// (encrypt) converts all plaintext to ciphertext. + /// Returns false if any batch failed. + /// + private async Task MigrateEntities(string entityName, CancellationToken stoppingToken) where T : class + { + var totalProcessed = 0; + var hadErrors = false; + var offset = 0; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var batch = await db.Set() + .OrderBy(e => EF.Property(e, "Id")) + .Skip(offset) + .Take(BatchSize) + .ToListAsync(stoppingToken); + + if (batch.Count == 0) + break; + + foreach (var entity in batch) + db.Entry(entity).State = EntityState.Modified; + + await db.SaveChangesAsync(stoppingToken); + totalProcessed += batch.Count; + offset += batch.Count; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Batch failed for {Entity} at offset {Offset} -- skipping batch", entityName, offset); + hadErrors = true; + offset += BatchSize; + } + } + + if (totalProcessed > 0) + logger.LogInformation("Encrypted {Count} {Entity}", totalProcessed, entityName); + + return !hadErrors; + } + + /// + /// UserFact has a global query filter (IsDeleted), so we need IgnoreQueryFilters + /// to encrypt soft-deleted facts too. + /// + private async Task MigrateUserFacts(CancellationToken stoppingToken) + { + var totalProcessed = 0; + var hadErrors = false; + var offset = 0; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var batch = await db.UserFacts + .IgnoreQueryFilters() + .OrderBy(f => f.Id) + .Skip(offset) + .Take(BatchSize) + .ToListAsync(stoppingToken); + + if (batch.Count == 0) + break; + + foreach (var fact in batch) + db.Entry(fact).State = EntityState.Modified; + + await db.SaveChangesAsync(stoppingToken); + totalProcessed += batch.Count; + offset += batch.Count; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Batch failed for UserFacts at offset {Offset} -- skipping batch", offset); + hadErrors = true; + offset += BatchSize; + } + } + + if (totalProcessed > 0) + logger.LogInformation("Encrypted {Count} UserFacts", totalProcessed); + + return !hadErrors; + } +} diff --git a/src/Orbit.Infrastructure/Services/EncryptionService.cs b/src/Orbit.Infrastructure/Services/EncryptionService.cs new file mode 100644 index 00000000..0c5732aa --- /dev/null +++ b/src/Orbit.Infrastructure/Services/EncryptionService.cs @@ -0,0 +1,138 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Services; + +public sealed class EncryptionService : IEncryptionService +{ + private const int NonceSize = 12; // AES-GCM standard nonce size + private const int TagSize = 16; // AES-GCM standard tag size + private const string EncPrefix = "enc:"; // Deterministic prefix for encrypted values + + private readonly byte[]? _key; + private readonly bool _isConfigured; + + public EncryptionService(IOptions settings, ILogger logger) + { + try + { + var keyString = settings.Value.Key; + + if (string.IsNullOrEmpty(keyString) || keyString.Contains("REPLACE")) + { + logger.LogWarning("Encryption key not configured -- encryption is disabled (passthrough mode)"); + _isConfigured = false; + return; + } + + _key = Convert.FromBase64String(keyString); + + if (_key.Length != 32) + throw new ArgumentException("Encryption key must be 256 bits (32 bytes) when decoded from Base64."); + + _isConfigured = true; + } + catch (FormatException) + { + logger.LogWarning("Encryption key is not valid Base64 -- encryption is disabled (passthrough mode)"); + _isConfigured = false; + } + } + + /// + /// Whether encryption is properly configured with valid keys. + /// When false, Encrypt/Decrypt act as passthrough (return input unchanged). + /// + public bool IsConfigured => _isConfigured; + + public string Encrypt(string plaintext) + { + if (!_isConfigured) + return plaintext; + + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var nonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(nonce); + + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(_key!, TagSize); + aes.Encrypt(nonce, plaintextBytes, ciphertext, tag); + + // Format: nonce + ciphertext + tag, then Base64 encode + var result = new byte[NonceSize + ciphertext.Length + TagSize]; + nonce.CopyTo(result, 0); + ciphertext.CopyTo(result, NonceSize); + tag.CopyTo(result, NonceSize + ciphertext.Length); + + return EncPrefix + Convert.ToBase64String(result); + } + + public string Decrypt(string ciphertextBase64) + { + if (!_isConfigured) + return ciphertextBase64; + + // New format: prefixed with "enc:" + if (ciphertextBase64.StartsWith(EncPrefix)) + { + var decrypted = DecryptRaw(ciphertextBase64[EncPrefix.Length..]); + // Handle double-encryption: legacy data that was re-encrypted with prefix + return TryDecryptLegacy(decrypted); + } + + // Legacy format: encrypted without prefix (migration transition). + return TryDecryptLegacy(ciphertextBase64); + } + + private string DecryptRaw(string base64) + { + var combined = Convert.FromBase64String(base64); + + if (combined.Length < NonceSize + TagSize) + throw new CryptographicException("Ciphertext is too short to contain nonce and tag."); + + var nonce = combined[..NonceSize]; + var tag = combined[^TagSize..]; + var ciphertext = combined[NonceSize..^TagSize]; + + var plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(_key!, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return Encoding.UTF8.GetString(plaintext); + } + + private string TryDecryptLegacy(string value) + { + try + { + var bytes = Convert.FromBase64String(value); + if (bytes.Length < NonceSize + TagSize) + return value; + + return DecryptRaw(value); + } + catch + { + // Not valid Base64 or decryption failed -- it's plaintext + return value; + } + } + + public string? EncryptNullable(string? plaintext) + { + return plaintext is null ? null : Encrypt(plaintext); + } + + public string? DecryptNullable(string? ciphertext) + { + return ciphertext is null ? null : Decrypt(ciphertext); + } +} diff --git a/src/Orbit.Infrastructure/Services/StripeCouponRewardService.cs b/src/Orbit.Infrastructure/Services/StripeCouponRewardService.cs new file mode 100644 index 00000000..5e31597a --- /dev/null +++ b/src/Orbit.Infrastructure/Services/StripeCouponRewardService.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Stripe; + +namespace Orbit.Infrastructure.Services; + +public class StripeCouponRewardService( + IGenericRepository userRepository, + IUnitOfWork unitOfWork, + ILogger logger) : IReferralRewardService +{ + private const string ProductId = "prod_UBUPrTlZg8chuk"; + + public async Task CreateReferralCouponAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == userId, + cancellationToken: cancellationToken); + + if (user is null) + throw new InvalidOperationException($"User {userId} not found for coupon creation"); + + // Ensure user has a Stripe customer + if (string.IsNullOrEmpty(user.StripeCustomerId)) + { + var customerService = new CustomerService(); + var customer = await customerService.CreateAsync(new CustomerCreateOptions + { + Email = user.Email, + Name = user.Name, + Metadata = new Dictionary { { "userId", userId.ToString() } } + }, cancellationToken: cancellationToken); + user.SetStripeCustomerId(customer.Id); + await unitOfWork.SaveChangesAsync(cancellationToken); + } + + var couponService = new CouponService(); + var coupon = await couponService.CreateAsync(new CouponCreateOptions + { + PercentOff = AppConstants.ReferralDiscountPercent, + Duration = "once", + MaxRedemptions = 1, + Name = "Referral Discount", + AppliesTo = new CouponAppliesToOptions + { + Products = [ProductId] + } + }, cancellationToken: cancellationToken); + + var promoCodeService = new PromotionCodeService(); + var promoCode = await promoCodeService.CreateAsync(new PromotionCodeCreateOptions + { + Promotion = new PromotionCodePromotionOptions { Coupon = coupon.Id }, + Customer = user.StripeCustomerId, + MaxRedemptions = 1 + }, cancellationToken: cancellationToken); + + logger.LogInformation( + "Created referral coupon for user {UserId}: coupon={CouponId}, promoCode={PromoCodeId}", + userId, coupon.Id, promoCode.Id); + + return promoCode.Id; + } + + public async Task GetUserPromotionCodeAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await userRepository.GetByIdAsync(userId, cancellationToken); + return user?.ReferralCouponId; + } +} diff --git a/src/Orbit.Infrastructure/Services/StripeSubscriptionRewardService.cs b/src/Orbit.Infrastructure/Services/StripeSubscriptionRewardService.cs deleted file mode 100644 index ee9af4fb..00000000 --- a/src/Orbit.Infrastructure/Services/StripeSubscriptionRewardService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Extensions.Logging; -using Orbit.Domain.Interfaces; -using Stripe; - -namespace Orbit.Infrastructure.Services; - -public class StripeSubscriptionRewardService( - ILogger logger) : ISubscriptionRewardService -{ - public async Task ExtendSubscriptionAsync(string subscriptionId, int days, CancellationToken cancellationToken = default) - { - var subscriptionService = new SubscriptionService(); - var subscription = await subscriptionService.GetAsync(subscriptionId, cancellationToken: cancellationToken); - - // Calculate the new trial_end date: - // If there's already a trial_end set (from a previous referral), extend from that. - // Otherwise, extend from the current_period_end (next billing date). - var baseDate = subscription.TrialEnd - ?? subscription.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd - ?? DateTime.UtcNow; - - var newTrialEnd = baseDate.AddDays(days); - - await subscriptionService.UpdateAsync(subscriptionId, new SubscriptionUpdateOptions - { - TrialEnd = newTrialEnd, - ProrationBehavior = "none" - }, cancellationToken: cancellationToken); - - logger.LogInformation( - "Extended Stripe subscription {SubscriptionId} trial_end to {TrialEnd} (+{Days} days)", - subscriptionId, newTrialEnd, days); - } -} diff --git a/tests/Orbit.Application.Tests/Commands/Referrals/CheckReferralCompletionCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Referrals/CheckReferralCompletionCommandHandlerTests.cs index b9137715..24d3e364 100644 --- a/tests/Orbit.Application.Tests/Commands/Referrals/CheckReferralCompletionCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Referrals/CheckReferralCompletionCommandHandlerTests.cs @@ -16,9 +16,8 @@ public class CheckReferralCompletionCommandHandlerTests private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IGenericRepository _habitLogRepo = Substitute.For>(); private readonly IGenericRepository _notificationRepo = Substitute.For>(); - private readonly IAppConfigService _appConfig = Substitute.For(); private readonly IPushNotificationService _pushNotification = Substitute.For(); - private readonly ISubscriptionRewardService _subscriptionReward = Substitute.For(); + private readonly IReferralRewardService _referralReward = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly CheckReferralCompletionCommandHandler _handler; @@ -29,11 +28,11 @@ public CheckReferralCompletionCommandHandlerTests() { _handler = new CheckReferralCompletionCommandHandler( _userRepo, _referralRepo, _habitRepo, _habitLogRepo, - _notificationRepo, _appConfig, _pushNotification, - _subscriptionReward, _unitOfWork); + _notificationRepo, _pushNotification, + _referralReward, _unitOfWork); - _appConfig.GetAsync("ReferralRewardDays", AppConstants.DefaultReferralRewardDays, Arg.Any()) - .Returns(AppConstants.DefaultReferralRewardDays); + _referralReward.CreateReferralCouponAsync(Arg.Any(), Arg.Any()) + .Returns("promo_test123"); } private static User CreateReferrer() @@ -160,18 +159,15 @@ public async Task Handle_InsufficientLogs_ReturnsSuccessNoOp() } [Fact] - public async Task Handle_ThresholdMet_FreeReferrer_CompletesAndExtendsReferrerTrial() + public async Task Handle_ThresholdMet_CreatesCouponForReferrer() { var referral = CreatePendingReferral(); var referredUser = CreateReferredUser(); var referrer = CreateReferrer(); - // Referrer is free/trial user (default state) SetupPendingReferral(referral); SetupReferredAndReferrerUsers(referredUser, referrer); SetupHabitsAndLogs(ReferredUserId, 1, AppConstants.ReferralCompletionThreshold); - var referrerTrialBefore = referrer.TrialEndsAt!.Value; - var command = new CheckReferralCompletionCommand(ReferredUserId); var result = await _handler.Handle(command, CancellationToken.None); @@ -180,17 +176,15 @@ public async Task Handle_ThresholdMet_FreeReferrer_CompletesAndExtendsReferrerTr referral.Status.Should().Be(ReferralStatus.Rewarded); referral.CompletedAtUtc.Should().NotBeNull(); referral.RewardGrantedAtUtc.Should().NotBeNull(); - // Free user gets trial extension - referrer.TrialEndsAt!.Value.Should().BeCloseTo( - referrerTrialBefore.AddDays(AppConstants.DefaultReferralRewardDays), - TimeSpan.FromSeconds(2)); - await _subscriptionReward.DidNotReceive().ExtendSubscriptionAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + // Coupon should be created for the referrer + await _referralReward.Received(1).CreateReferralCouponAsync( + ReferrerId, Arg.Any()); + referrer.ReferralCouponId.Should().Be("promo_test123"); await _unitOfWork.Received().SaveChangesAsync(Arg.Any()); } [Fact] - public async Task Handle_ThresholdMet_ProReferrer_UsesSubscriptionService() + public async Task Handle_ThresholdMet_ProReferrer_StillGetsCoupon() { var referral = CreatePendingReferral(); var referredUser = CreateReferredUser(); @@ -207,8 +201,9 @@ public async Task Handle_ThresholdMet_ProReferrer_UsesSubscriptionService() result.IsSuccess.Should().BeTrue(); referral.Status.Should().Be(ReferralStatus.Rewarded); - await _subscriptionReward.Received(1).ExtendSubscriptionAsync( - "sub_test123", AppConstants.DefaultReferralRewardDays, Arg.Any()); + // Pro users also get a coupon (same behavior for all users now) + await _referralReward.Received(1).CreateReferralCouponAsync( + ReferrerId, Arg.Any()); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/Referrals/ProcessReferralCodeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Referrals/ProcessReferralCodeCommandHandlerTests.cs index d158117f..6f299753 100644 --- a/tests/Orbit.Application.Tests/Commands/Referrals/ProcessReferralCodeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Referrals/ProcessReferralCodeCommandHandlerTests.cs @@ -14,6 +14,7 @@ public class ProcessReferralCodeCommandHandlerTests private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IGenericRepository _referralRepo = Substitute.For>(); private readonly IAppConfigService _appConfig = Substitute.For(); + private readonly IReferralRewardService _referralReward = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly ProcessReferralCodeCommandHandler _handler; @@ -23,13 +24,14 @@ public class ProcessReferralCodeCommandHandlerTests public ProcessReferralCodeCommandHandlerTests() { _handler = new ProcessReferralCodeCommandHandler( - _userRepo, _referralRepo, _appConfig, _unitOfWork); + _userRepo, _referralRepo, _appConfig, _referralReward, _unitOfWork); // Default config values _appConfig.GetAsync("MaxReferrals", AppConstants.DefaultMaxReferrals, Arg.Any()) .Returns(AppConstants.DefaultMaxReferrals); - _appConfig.GetAsync("ReferralRewardDays", AppConstants.DefaultReferralRewardDays, Arg.Any()) - .Returns(AppConstants.DefaultReferralRewardDays); + + _referralReward.CreateReferralCouponAsync(Arg.Any(), Arg.Any()) + .Returns("promo_test456"); } private static User CreateReferrer() @@ -62,7 +64,7 @@ private void SetupUsersFound(User referrer, User newUser) } [Fact] - public async Task Handle_ValidCode_LinksReferrerAndExtendsTrialAndCreatesReferral() + public async Task Handle_ValidCode_LinksReferrerAndCreatesCouponAndReferral() { var referrer = CreateReferrer(); var newUser = CreateNewUser(); @@ -87,11 +89,10 @@ await _referralRepo.Received(1).AddAsync( } [Fact] - public async Task Handle_ValidCode_ExtendsNewUserTrial() + public async Task Handle_ValidCode_CreatesCouponForNewUser() { var referrer = CreateReferrer(); var newUser = CreateNewUser(); - var originalTrialEnd = newUser.TrialEndsAt!.Value; SetupUsersFound(referrer, newUser); _referralRepo.FindAsync( @@ -103,10 +104,10 @@ public async Task Handle_ValidCode_ExtendsNewUserTrial() await _handler.Handle(command, CancellationToken.None); - // Trial should be extended by default reward days (10) - newUser.TrialEndsAt!.Value.Should().BeCloseTo( - originalTrialEnd.AddDays(AppConstants.DefaultReferralRewardDays), - TimeSpan.FromSeconds(2)); + // Coupon should be created for the new user + await _referralReward.Received(1).CreateReferralCouponAsync( + NewUserId, Arg.Any()); + newUser.ReferralCouponId.Should().Be("promo_test456"); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Referrals/GetReferralStatsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Referrals/GetReferralStatsQueryHandlerTests.cs index 8028afde..c906312f 100644 --- a/tests/Orbit.Application.Tests/Queries/Referrals/GetReferralStatsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Referrals/GetReferralStatsQueryHandlerTests.cs @@ -24,8 +24,6 @@ public GetReferralStatsQueryHandlerTests() _appConfig.GetAsync("MaxReferrals", AppConstants.DefaultMaxReferrals, Arg.Any()) .Returns(AppConstants.DefaultMaxReferrals); - _appConfig.GetAsync("ReferralRewardDays", AppConstants.DefaultReferralRewardDays, Arg.Any()) - .Returns(AppConstants.DefaultReferralRewardDays); } private static User CreateTestUser() @@ -50,7 +48,7 @@ public async Task Handle_UserNotFound_ReturnsFailure() } [Fact] - public async Task Handle_NoReferrals_ReturnsZeroCounts() + public async Task Handle_NoReferrals_ReturnsZeroCountsWithDiscountInfo() { var user = CreateTestUser(); user.SetReferralCode("CODE1234"); @@ -70,7 +68,8 @@ public async Task Handle_NoReferrals_ReturnsZeroCounts() result.Value.SuccessfulReferrals.Should().Be(0); result.Value.PendingReferrals.Should().Be(0); result.Value.MaxReferrals.Should().Be(AppConstants.DefaultMaxReferrals); - result.Value.RewardDays.Should().Be(AppConstants.DefaultReferralRewardDays); + result.Value.RewardType.Should().Be("discount"); + result.Value.DiscountPercent.Should().Be(AppConstants.ReferralDiscountPercent); } [Fact] diff --git a/tests/Orbit.IntegrationTests/EncryptionTests.cs b/tests/Orbit.IntegrationTests/EncryptionTests.cs new file mode 100644 index 00000000..8a04bd57 --- /dev/null +++ b/tests/Orbit.IntegrationTests/EncryptionTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class EncryptionTests +{ + private readonly IEncryptionService _encryptionService; + + public EncryptionTests() + { + var settings = Options.Create(new EncryptionSettings + { + Key = "DdyUCjjdK326cB9lY00tyUvRDpCQcYJOJIpu21I1D8c=" + }); + _encryptionService = new EncryptionService(settings, NullLogger.Instance); + } + + [Fact] + public void Encrypt_Decrypt_Roundtrip_ReturnsOriginalValue() + { + var plaintext = "test@example.com"; + + var encrypted = _encryptionService.Encrypt(plaintext); + var decrypted = _encryptionService.Decrypt(encrypted); + + decrypted.Should().Be(plaintext); + } + + [Fact] + public void Encrypt_ProducesDifferentCiphertextEachTime() + { + var plaintext = "same-input"; + + var encrypted1 = _encryptionService.Encrypt(plaintext); + var encrypted2 = _encryptionService.Encrypt(plaintext); + + encrypted1.Should().NotBe(encrypted2, "AES-GCM uses random nonce, so encryptions differ"); + } + + [Fact] + public void Encrypt_Decrypt_HandlesEmptyString() + { + var plaintext = ""; + + var encrypted = _encryptionService.Encrypt(plaintext); + var decrypted = _encryptionService.Decrypt(encrypted); + + decrypted.Should().Be(plaintext); + } + + [Fact] + public void Encrypt_Decrypt_HandlesLongString() + { + var plaintext = new string('A', 10000); + + var encrypted = _encryptionService.Encrypt(plaintext); + var decrypted = _encryptionService.Decrypt(encrypted); + + decrypted.Should().Be(plaintext); + } + + [Fact] + public void Encrypt_Decrypt_HandlesUnicodeCharacters() + { + var plaintext = "Joao Carlos da Silva"; + + var encrypted = _encryptionService.Encrypt(plaintext); + var decrypted = _encryptionService.Decrypt(encrypted); + + decrypted.Should().Be(plaintext); + } + + [Fact] + public void Encrypt_OutputStartsWithPrefix() + { + var encrypted = _encryptionService.Encrypt("test"); + + encrypted.Should().StartWith("enc:"); + } + + [Fact] + public void Decrypt_PlaintextPassthrough_ReturnsAsIs() + { + var plaintext = "not-encrypted@email.com"; + + var result = _encryptionService.Decrypt(plaintext); + + result.Should().Be(plaintext); + } + + [Fact] + public void Decrypt_LongBase64Plaintext_ReturnsAsIs() + { + var url = "https://fcm.googleapis.com/fcm/send/cY2M7GEaPK0:APA91bHxyz"; + + var result = _encryptionService.Decrypt(url); + + result.Should().Be(url); + } + + [Fact] + public void EncryptNullable_NullInput_ReturnsNull() + { + var result = _encryptionService.EncryptNullable(null); + + result.Should().BeNull(); + } + + [Fact] + public void DecryptNullable_NullInput_ReturnsNull() + { + var result = _encryptionService.DecryptNullable(null); + + result.Should().BeNull(); + } + + [Fact] + public void EncryptNullable_DecryptNullable_Roundtrip() + { + var plaintext = "nullable-test-value"; + + var encrypted = _encryptionService.EncryptNullable(plaintext); + var decrypted = _encryptionService.DecryptNullable(encrypted); + + encrypted.Should().NotBeNull(); + decrypted.Should().Be(plaintext); + } + + [Fact] + public void Constructor_InvalidKeyLength_ThrowsArgumentException() + { + var badSettings = Options.Create(new EncryptionSettings + { + Key = Convert.ToBase64String(new byte[16]) // 16 bytes instead of 32 + }); + + var act = () => new EncryptionService(badSettings, NullLogger.Instance); + + act.Should().Throw().WithMessage("*256 bits*"); + } + + [Fact] + public void Constructor_PlaceholderKey_EnablesPassthroughMode() + { + var placeholderSettings = Options.Create(new EncryptionSettings + { + Key = "REPLACE-IN-DEVELOPMENT-JSON" + }); + + var service = new EncryptionService(placeholderSettings, NullLogger.Instance); + + service.Encrypt("hello").Should().Be("hello"); + service.Decrypt("hello").Should().Be("hello"); + } +} diff --git a/tests/Orbit.IntegrationTests/appsettings.json b/tests/Orbit.IntegrationTests/appsettings.json index 3ecf8dab..367ca9e9 100644 --- a/tests/Orbit.IntegrationTests/appsettings.json +++ b/tests/Orbit.IntegrationTests/appsettings.json @@ -18,5 +18,8 @@ "Issuer": "OrbitTestApi", "Audience": "OrbitTestClient", "ExpiryHours": 24 + }, + "Encryption": { + "Key": "DdyUCjjdK326cB9lY00tyUvRDpCQcYJOJIpu21I1D8c=" } }