Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ Desktop.ini

## Docker / Caddy
caddy_data/
caddy_config/
caddy_config/.claude/worktrees/
25 changes: 21 additions & 4 deletions src/Orbit.Api/Controllers/SubscriptionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public async Task<IActionResult> 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
Expand Down Expand Up @@ -85,16 +85,25 @@ public async Task<IActionResult> 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",
LineItems = [new SessionLineItemOptions { Price = priceId, Quantity = 1 }],
SuccessUrl = _settings.SuccessUrl,
CancelUrl = _settings.CancelUrl,
Metadata = new Dictionary<string, string> { { "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));
Expand Down Expand Up @@ -199,6 +208,14 @@ public async Task<IActionResult> 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);
}
Expand Down
8 changes: 7 additions & 1 deletion src/Orbit.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@

var builder = WebApplication.CreateBuilder(args);

// --- Encryption ---
builder.Services.Configure<EncryptionSettings>(
builder.Configuration.GetSection(EncryptionSettings.SectionName));
builder.Services.AddSingleton<IEncryptionService, EncryptionService>();

// --- Database ---
builder.Services.AddDbContext<OrbitDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
Expand Down Expand Up @@ -67,12 +72,13 @@
builder.Services.Configure<VapidSettings>(
builder.Configuration.GetSection(VapidSettings.SectionName));
builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
builder.Services.AddScoped<ISubscriptionRewardService, StripeSubscriptionRewardService>();
builder.Services.AddScoped<IReferralRewardService, StripeCouponRewardService>();
builder.Services.AddHostedService<ReminderSchedulerService>();
builder.Services.AddHostedService<GoalDeadlineNotificationService>();
builder.Services.AddHostedService<SlipAlertSchedulerService>();
builder.Services.AddHostedService<AccountDeletionService>();
builder.Services.AddHostedService<HabitDueDateAdvancementService>();
builder.Services.AddHostedService<DataEncryptionMigrationService>();
builder.Services.AddHttpClient<ISlipAlertMessageService, GeminiSlipAlertMessageService>();

// Initialize Firebase Admin SDK for FCM
Expand Down
3 changes: 3 additions & 0 deletions src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"PrivateKey": "REPLACE-IN-DEVELOPMENT-JSON",
"Subject": "mailto:hello@useorbit.org"
},
"Encryption": {
"Key": "REPLACE-IN-DEVELOPMENT-JSON"
},
"Google": {
"ClientId": "",
"ClientSecret": ""
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task<Result<LoginResponse>> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public async Task<Result<LoginResponse>> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ public class CheckReferralCompletionCommandHandler(
IGenericRepository<Habit> habitRepository,
IGenericRepository<HabitLog> habitLogRepository,
IGenericRepository<Notification> notificationRepository,
IAppConfigService appConfigService,
IPushNotificationService pushNotificationService,
ISubscriptionRewardService subscriptionRewardService,
IReferralRewardService referralRewardService,
IUnitOfWork unitOfWork) : IRequestHandler<CheckReferralCompletionCommand, Result>
{
public async Task<Result> Handle(CheckReferralCompletionCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -71,27 +70,17 @@ public async Task<Result> 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();
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class ProcessReferralCodeCommandHandler(
IGenericRepository<User> userRepository,
IGenericRepository<Referral> referralRepository,
IAppConfigService appConfigService,
IReferralRewardService referralRewardService,
IUnitOfWork unitOfWork) : IRequestHandler<ProcessReferralCodeCommand, Result>
{
public async Task<Result> Handle(ProcessReferralCodeCommand request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -51,9 +52,10 @@ public async Task<Result> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<ReferralStatsResponse>>;

Expand All @@ -37,8 +38,6 @@ public async Task<Result<ReferralStatsResponse>> 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}"
Expand All @@ -50,6 +49,7 @@ public async Task<Result<ReferralStatsResponse>> Handle(GetReferralStatsQuery re
successful,
pending,
maxReferrals,
rewardDays));
"discount",
AppConstants.ReferralDiscountPercent));
}
}
3 changes: 3 additions & 0 deletions src/Orbit.Domain/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/Orbit.Domain/Interfaces/IEncryptionService.cs
Original file line number Diff line number Diff line change
@@ -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);
}
15 changes: 15 additions & 0 deletions src/Orbit.Domain/Interfaces/IReferralRewardService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Orbit.Domain.Interfaces;

public interface IReferralRewardService
{
/// <summary>
/// Ensures the user has a Stripe customer, creates a one-time 10% discount coupon,
/// and returns the Stripe promotion code ID.
/// </summary>
Task<string> CreateReferralCouponAsync(Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves the user's stored referral promotion code ID, or null if none exists.
/// </summary>
Task<string?> GetUserPromotionCodeAsync(Guid userId, CancellationToken cancellationToken = default);
}
10 changes: 0 additions & 10 deletions src/Orbit.Domain/Interfaces/ISubscriptionRewardService.cs

This file was deleted.

7 changes: 7 additions & 0 deletions src/Orbit.Infrastructure/Configuration/EncryptionSettings.cs
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<string>("ReferralCode")
.HasColumnType("text");

b.Property<string>("ReferralCouponId")
.HasColumnType("text");

b.Property<Guid?>("ReferredByUserId")
.HasColumnType("uuid");

Expand Down
32 changes: 32 additions & 0 deletions src/Orbit.Infrastructure/Persistence/EncryptionValueConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Orbit.Domain.Interfaces;

namespace Orbit.Infrastructure.Persistence;

/// <summary>
/// EF Core ValueConverter that encrypts string values on write and decrypts on read.
/// For non-nullable string columns.
/// </summary>
public sealed class EncryptionValueConverter : ValueConverter<string, string>
{
public EncryptionValueConverter(IEncryptionService encryptionService)
: base(
v => encryptionService.Encrypt(v),
v => encryptionService.Decrypt(v))
{
}
}

/// <summary>
/// EF Core ValueConverter that encrypts nullable string values on write and decrypts on read.
/// Passes through null without encrypting.
/// </summary>
public sealed class NullableEncryptionValueConverter : ValueConverter<string?, string?>
{
public NullableEncryptionValueConverter(IEncryptionService encryptionService)
: base(
v => encryptionService.EncryptNullable(v),
v => encryptionService.DecryptNullable(v))
{
}
}
Loading
Loading