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
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,31 @@ public class CheckReferralCompletionCommandHandler(
{
public async Task<Result> Handle(CheckReferralCompletionCommand request, CancellationToken cancellationToken)
{
// Find pending referral where this user is the referred user
var pendingReferrals = await referralRepository.FindAsync(
r => r.ReferredUserId == request.UserId && r.Status == ReferralStatus.Pending,
cancellationToken: cancellationToken);

var referral = pendingReferrals.FirstOrDefault();
if (referral is null)
return Result.Success(); // No pending referral, nothing to do
return Result.Success();

// Need tracked entity for state changes
var trackedReferral = await referralRepository.FindOneTrackedAsync(
r => r.Id == referral.Id,
cancellationToken: cancellationToken);

if (trackedReferral is null)
return Result.Success();

// Load the referred user to check signup date
var referredUser = await userRepository.FindOneTrackedAsync(
u => u.Id == request.UserId,
cancellationToken: cancellationToken);

if (referredUser is null)
return Result.Success();

// Check if within the completion window
if (DateTime.UtcNow > referredUser.CreatedAtUtc.AddDays(AppConstants.ReferralCompletionWindowDays))
return Result.Success(); // Window expired, leave as Pending
return Result.Success();

// Count total habit logs for this user
// HabitLog doesn't have UserId, so first get user's habit IDs, then count logs
var userHabits = await habitRepository.FindAsync(
h => h.UserId == request.UserId,
cancellationToken: cancellationToken);
Expand All @@ -65,70 +59,79 @@ public async Task<Result> Handle(CheckReferralCompletionCommand request, Cancell
cancellationToken: cancellationToken);

if (userLogs.Count < AppConstants.ReferralCompletionThreshold)
return Result.Success(); // Not enough logs yet
return Result.Success();

// Mark referral as completed
trackedReferral.MarkCompleted();

// Grant discount coupon to both referrer and referred user
// Grant coupon to referrer
var referrer = await userRepository.FindOneTrackedAsync(
u => u.Id == trackedReferral.ReferrerId,
cancellationToken: cancellationToken);

if (referrer is not null)
{
var referrerPromoId = await referralRewardService.CreateReferralCouponAsync(
referrer.Id, cancellationToken);
referrer.SetReferralCoupon(referrerPromoId);
}
await GrantCoupon(referrer, cancellationToken);

// Referred user also gets a coupon
var referredPromoId = await referralRewardService.CreateReferralCouponAsync(
referredUser.Id, cancellationToken);
referredUser.SetReferralCoupon(referredPromoId);
// Grant coupon to referred user
await GrantCoupon(referredUser, cancellationToken);

trackedReferral.MarkRewarded();
await unitOfWork.SaveChangesAsync(cancellationToken);

// Send notifications to both users
// Send notifications
if (referrer is not null)
await SendNotification(referrer, isReferrer: true, cancellationToken);

await SendNotification(referredUser, isReferrer: false, cancellationToken);

await unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}

/// <summary>
/// If user is Pro with active subscription, apply coupon to next invoice directly.
/// Otherwise, store coupon ID for checkout.
/// </summary>
private async Task GrantCoupon(User user, CancellationToken cancellationToken)
{
var couponId = await referralRewardService.CreateReferralCouponAsync(
user.Id, cancellationToken);

if (user.IsPro && !string.IsNullOrEmpty(user.StripeSubscriptionId))
{
var isPtReferrer = referrer.Language?.StartsWith("pt") == true;
var referrerTitle = isPtReferrer ? "Indicacao Concluida!" : "Referral Completed!";
var referrerBody = isPtReferrer
? "Seu amigo comecou a usar o Orbit e voce ganhou um cupom de 10% de desconto no Pro!"
: "Your friend joined Orbit and you earned a 10% discount coupon for Pro!";

await notificationRepository.AddAsync(
Notification.Create(referrer.Id, referrerTitle, referrerBody, "/profile"), cancellationToken);

_ = Task.Run(async () =>
{
try { await pushNotificationService.SendToUserAsync(referrer.Id, referrerTitle, referrerBody, "/profile", CancellationToken.None); }
catch { }
}, CancellationToken.None);
await referralRewardService.ApplyCouponToSubscriptionAsync(
user.StripeSubscriptionId, couponId, cancellationToken);
}

// Notify referred user they earned a coupon too
if (referredUser is not null)
else
{
var isPtReferred = referredUser.Language?.StartsWith("pt") == true;
var referredTitle = isPtReferred ? "Voce ganhou um cupom!" : "You earned a coupon!";
var referredBody = isPtReferred
? "Bem-vindo ao Orbit! Voce ganhou um cupom de 10% de desconto no Pro!"
: "Welcome to Orbit! You earned a 10% discount coupon for Pro!";

await notificationRepository.AddAsync(
Notification.Create(referredUser.Id, referredTitle, referredBody, "/profile"), cancellationToken);

_ = Task.Run(async () =>
{
try { await pushNotificationService.SendToUserAsync(referredUser.Id, referredTitle, referredBody, "/profile", CancellationToken.None); }
catch { }
}, CancellationToken.None);
user.SetReferralCoupon(couponId);
}
}

await unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
private async Task SendNotification(User user, bool isReferrer, CancellationToken cancellationToken)
{
var isPt = user.Language?.StartsWith("pt") == true;

var (title, body) = isReferrer
? (isPt ? "Indicacao Concluida!" : "Referral Completed!",
isPt
? user.IsPro
? "Seu amigo comecou a usar o Orbit! 10% de desconto aplicado na sua proxima fatura."
: "Seu amigo comecou a usar o Orbit e voce ganhou um cupom de 10% de desconto no Pro!"
: user.IsPro
? "Your friend joined Orbit! 10% discount applied to your next invoice."
: "Your friend joined Orbit and you earned a 10% discount coupon for Pro!")
: (isPt ? "Voce ganhou um cupom!" : "You earned a coupon!",
isPt
? "Bem-vindo ao Orbit! Voce ganhou um cupom de 10% de desconto no Pro!"
: "Welcome to Orbit! You earned a 10% discount coupon for Pro!");

await notificationRepository.AddAsync(
Notification.Create(user.Id, title, body, "/profile"), cancellationToken);

_ = Task.Run(async () =>
{
try { await pushNotificationService.SendToUserAsync(user.Id, title, body, "/profile", CancellationToken.None); }
catch { }
}, CancellationToken.None);
}
}
7 changes: 3 additions & 4 deletions src/Orbit.Domain/Interfaces/IReferralRewardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ 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.
/// Creates a one-time 10% discount coupon and returns the Stripe coupon 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.
/// Applies a coupon to an existing Stripe subscription's next invoice.
/// </summary>
Task<string?> GetUserPromotionCodeAsync(Guid userId, CancellationToken cancellationToken = default);
Task ApplyCouponToSubscriptionAsync(string subscriptionId, string couponId, CancellationToken cancellationToken = default);
}
14 changes: 10 additions & 4 deletions src/Orbit.Infrastructure/Services/StripeCouponRewardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ namespace Orbit.Infrastructure.Services;

public class StripeCouponRewardService(
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork,
ILogger<StripeCouponRewardService> logger) : IReferralRewardService
{
private const string ProductId = "prod_UBUPrTlZg8chuk";
Expand Down Expand Up @@ -42,9 +41,16 @@ public async Task<string> CreateReferralCouponAsync(Guid userId, CancellationTok
return coupon.Id;
}

public async Task<string?> GetUserPromotionCodeAsync(Guid userId, CancellationToken cancellationToken = default)
public async Task ApplyCouponToSubscriptionAsync(string subscriptionId, string couponId, CancellationToken cancellationToken = default)
{
var user = await userRepository.GetByIdAsync(userId, cancellationToken);
return user?.ReferralCouponId;
var subscriptionService = new SubscriptionService();
await subscriptionService.UpdateAsync(subscriptionId, new SubscriptionUpdateOptions
{
Discounts = [new SubscriptionDiscountOptions { Coupon = couponId }]
}, cancellationToken: cancellationToken);

logger.LogInformation(
"Applied coupon {CouponId} to subscription {SubscriptionId}",
couponId, subscriptionId);
}
}
Loading