Subscription gifts

This commit is contained in:
2025-10-03 14:36:27 +08:00
parent a93b633e84
commit fa24f14c05
18 changed files with 5273 additions and 537 deletions

View File

@@ -44,6 +44,7 @@ public class AppDatabase(
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
public DbSet<SnAccountPunishment> Punishments { get; set; } = null!;
@@ -278,4 +279,4 @@ public static class OptionalQueryExtensions
{
return condition ? transform(source) : source;
}
}
}

View File

@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonNetwork.Pass.csproj", "{0E8F6522-90DE-5BDE-7127-114E02C2C10F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8DAB9031-CC04-4A1A-A05A-4ADFEBAB90A8}
EndGlobalSection
EndGlobal

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -49,31 +49,64 @@ public class BroadcastEventHandler(
evt?.OrderId
);
if (evt?.ProductIdentifier is null ||
!evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
if (evt?.ProductIdentifier is null)
continue;
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
// Handle subscription orders
if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
// Handle gift orders
else if (evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true)
{
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleGiftOrder(order);
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else
{
// Not a subscription or gift order, skip
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
catch (Exception ex)
{

View File

@@ -46,6 +46,16 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(30)
.RepeatForever())
);
var giftCleanupJob = new JobKey("GiftCleanup");
q.AddJob<GiftCleanupJob>(opts => opts.WithIdentity(giftCleanupJob));
q.AddTrigger(opts => opts
.ForJob(giftCleanupJob)
.WithIdentity("GiftCleanupTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -0,0 +1,40 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class GiftCleanupJob(
AppDatabase db,
ILogger<GiftCleanupJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting gift cleanup job...");
var now = SystemClock.Instance.GetCurrentInstant();
// Clean up gifts that are in Created status and older than 24 hours
var cutoffTime = now.Minus(Duration.FromHours(24));
var oldCreatedGifts = await db.WalletGifts
.Where(g => g.Status == GiftStatus.Created)
.Where(g => g.CreatedAt < cutoffTime)
.ToListAsync();
if (oldCreatedGifts.Count == 0)
{
logger.LogInformation("No old created gifts to clean up");
return;
}
logger.LogInformation("Found {Count} old created gifts to clean up", oldCreatedGifts.Count);
// Remove the gifts
db.WalletGifts.RemoveRange(oldCreatedGifts);
await db.SaveChangesAsync();
logger.LogInformation("Successfully cleaned up {Count} old created gifts", oldCreatedGifts.Count);
}
}

View File

@@ -9,7 +9,10 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/subscriptions/gifts")]
public class GiftController(SubscriptionService subscriptions, AppDatabase db) : ControllerBase
public class SubscriptionGiftController(
SubscriptionService subscriptions,
AppDatabase db
) : ControllerBase
{
/// <summary>
/// Lists gifts purchased by the current user.
@@ -71,9 +74,9 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await db.WalletGifts
.Include(g => g.Gifter)
.Include(g => g.Recipient)
.Include(g => g.Redeemer)
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.Id == giftId);
@@ -101,7 +104,7 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
var canRedeem = false;
var error = "";
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
{
error = gift.Status switch
{
@@ -137,7 +140,8 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
var existingSubscription =
await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
if (existingSubscription is not null)
{
error = "You already have an active subscription of this type.";
@@ -147,7 +151,8 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
{
error = $"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
error =
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
}
else
{
@@ -310,4 +315,26 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
return BadRequest(ex.Message);
}
}
/// <summary>
/// Creates an order for an unpaid gift.
/// </summary>
[HttpPost("{giftId}/order")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateGiftOrder(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await subscriptions.CreateGiftOrder(currentUser.Id, giftId);
return order;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -42,6 +42,7 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -57,36 +58,42 @@ public class SubscriptionService(
if (existingSubscription is not null)
return existingSubscription;
// Batch database queries for account profile and coupon to reduce round trips
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null);
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponTask = coupon != null
? db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: Task.FromResult((SnWalletCoupon?)null);
// Await batched queries
var profile = await accountProfileTask;
var prevFreeTrial = await prevFreeTrialTask;
var couponData = await couponTask;
// Validation checks
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile is null)
throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
if (isFreeTrial)
{
var prevFreeTrial = await db.WalletSubscriptions
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
.FirstOrDefaultAsync();
if (prevFreeTrial is not null)
throw new InvalidOperationException("Free trial already exists.");
}
if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists.");
SnWalletCoupon? couponData = null;
if (coupon is not null)
{
var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty;
couponData = await db.WalletCoupons
.Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon))
.FirstOrDefaultAsync();
if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
}
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
var now = SystemClock.Instance.GetCurrentInstant();
var subscription = new SnWalletSubscription
@@ -266,6 +273,41 @@ public class SubscriptionService(
);
}
/// <summary>
/// Creates a gift order for an unpaid gift.
/// </summary>
/// <param name="accountId">The account ID of the gifter.</param>
/// <param name="giftId">The unique identifier for the gift.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the created gift order.</returns>
/// <exception cref="InvalidOperationException">Thrown when the gift is not found or not in payable status.</exception>
public async Task<SnWalletOrder> CreateGiftOrder(Guid accountId, Guid giftId)
{
var gift = await db.WalletGifts
.Where(g => g.Id == giftId && g.GifterId == accountId)
.Where(g => g.Status == DysonNetwork.Shared.Models.GiftStatus.Created)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null) throw new InvalidOperationException("No matching gift found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
gift.FinalPrice,
appIdentifier: "gift",
productIdentifier: gift.SubscriptionIdentifier,
meta: new Dictionary<string, object>()
{
["gift_id"] = gift.Id.ToString()
}
);
}
public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
@@ -285,14 +327,11 @@ public class SubscriptionService(
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
{
var now = SystemClock.Instance.GetCurrentInstant();
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
// Calculate original cycle duration and extend from the current ended date
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
}
subscription.Status = Shared.Models.SubscriptionStatus.Active;
@@ -305,6 +344,36 @@ public class SubscriptionService(
return subscription;
}
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["gift_id"] is not JsonElement giftIdJson)
throw new InvalidOperationException("Invalid order.");
var giftId = Guid.TryParse(giftIdJson.ToString(), out var parsedGiftId)
? parsedGiftId
: Guid.Empty;
if (giftId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var gift = await db.WalletGifts
.Where(g => g.Id == giftId)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null)
throw new InvalidOperationException("Invalid order.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift is not in payable status.");
// Mark gift as sent after payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(gift);
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Updates the status of expired subscriptions to reflect their current state.
/// This helps maintain accurate subscription records and is typically called periodically.
@@ -326,16 +395,19 @@ public class SubscriptionService(
if (expiredSubscriptions.Count == 0)
return 0;
// Mark as expired
foreach (var subscription in expiredSubscriptions)
{
subscription.Status = Shared.Models.SubscriptionStatus.Expired;
// Clear the cache for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}";
await cache.RemoveAsync(cacheKey);
}
await db.SaveChangesAsync();
// Batch invalidate caches for better performance
var cacheTasks = expiredSubscriptions.Select(subscription =>
cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"));
await Task.WhenAll(cacheTasks);
return expiredSubscriptions.Count;
}
@@ -379,10 +451,11 @@ public class SubscriptionService(
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{
// Create a unique cache key for this subscription
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers)));
var hashIdentifier = Convert.ToHexStringLower(hashBytes);
var identifierPart = identifiers.Length == 1
? identifiers[0]
: Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))));
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{hashIdentifier}";
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}";
// Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
@@ -443,17 +516,24 @@ public class SubscriptionService(
var missingAccountIds = new List<Guid>();
// Try to get the subscription from cache first
foreach (var accountId in accountIds)
var cacheTasks = accountIds.Select(async accountId =>
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
return (accountId, found, cachedSubscription);
});
var cacheResults = await Task.WhenAll(cacheTasks);
foreach (var (accountId, found, cachedSubscription) in cacheResults)
{
if (found && cachedSubscription != null)
result[accountId] = cachedSubscription;
else
missingAccountIds.Add(accountId);
}
if (missingAccountIds.Count <= 0) return result;
if (missingAccountIds.Count == 0) return result;
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
@@ -464,18 +544,443 @@ public class SubscriptionService(
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
subscriptions = subscriptions.Where(s => s.IsAvailable).ToList();
// Group the subscriptions by account id
foreach (var subscription in subscriptions)
// Group by account and select latest available subscription
var groupedSubscriptions = subscriptions
.Where(s => s.IsAvailable)
.GroupBy(s => s.AccountId)
.ToDictionary(g => g.Key, g => g.First());
// Update results and batch cache operations
var cacheSetTasks = new List<Task>();
foreach (var kvp in groupedSubscriptions)
{
result[subscription.AccountId] = subscription;
// Cache the result if found (with 30 minutes expiry)
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}";
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
result[kvp.Key] = kvp.Value;
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
}
await Task.WhenAll(cacheSetTasks);
return result;
}
}
/// <summary>
/// Purchases a gift subscription that can be redeemed by another user.
/// </summary>
/// <param name="gifter">The account purchasing the gift.</param>
/// <param name="recipientId">Optional specific recipient. If null, creates an open gift anyone can redeem.</param>
/// <param name="subscriptionIdentifier">The subscription type being gifted.</param>
/// <param name="paymentMethod">Payment method used by the gifter.</param>
/// <param name="paymentDetails">Payment details from the gifter.</param>
/// <param name="message">Optional personal message from the gifter.</param>
/// <param name="coupon">Optional coupon code for discount.</param>
/// <param name="giftDuration">How long the gift can be redeemed (default 30 days).</param>
/// <param name="cycleDuration">The duration of the subscription once redeemed (default 30 days).</param>
/// <returns>The created gift record.</returns>
public async Task<SnWalletGift> PurchaseGiftAsync(
SnAccount gifter,
Guid? recipientId,
string subscriptionIdentifier,
string paymentMethod,
SnPaymentDetails paymentDetails,
string? message = null,
string? coupon = null,
Duration? giftDuration = null,
Duration? cycleDuration = null)
{
// Validate subscription exists
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
// Check if recipient account exists (if specified)
SnAccount? recipient = null;
if (recipientId.HasValue)
{
recipient = await db.Accounts
.Where(a => a.Id == recipientId.Value)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
}
// Validate and get coupon if provided
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponData = coupon != null
? await db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: null;
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
// Set defaults
giftDuration ??= Duration.FromDays(30); // Gift expires in 30 days
cycleDuration ??= Duration.FromDays(30); // Subscription lasts 30 days once redeemed
var now = SystemClock.Instance.GetCurrentInstant();
// Generate unique gift code
var giftCode = await GenerateUniqueGiftCodeAsync();
// Calculate final price (with potential coupon discount)
var tempSubscription = new SnWalletSubscription
{
BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
BegunAt = now // Need for price calculation
};
var finalPrice = tempSubscription.CalculateFinalPriceAt(now);
var gift = new SnWalletGift
{
GifterId = gifter.Id,
RecipientId = recipientId,
GiftCode = giftCode,
Message = message,
SubscriptionIdentifier = subscriptionIdentifier,
BasePrice = subscriptionInfo.BasePrice,
FinalPrice = finalPrice,
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
ExpiresAt = now.Plus(giftDuration.Value),
IsOpenGift = !recipientId.HasValue,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
CouponId = couponData?.Id,
CreatedAt = now,
UpdatedAt = now
};
db.WalletGifts.Add(gift);
await db.SaveChangesAsync();
// Create order and process payment
var order = await payment.CreateOrderAsync(
null, // No specific payee wallet for gifts
subscriptionInfo.Currency,
finalPrice,
appIdentifier: "gift",
productIdentifier: subscriptionIdentifier,
meta: new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString()
}
);
// If payment method is in-app wallet, process payment immediately
if (paymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
var gifterWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == gifter.Id);
if (gifterWallet == null)
throw new InvalidOperationException("Gifter wallet not found.");
await payment.PayOrderAsync(order.Id, gifterWallet);
// Mark gift as sent after successful payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
}
return gift;
}
/// <summary>
/// Activates a gift using the redemption code, creating a subscription for the redeemer.
/// </summary>
/// <param name="redeemer">The account redeeming the gift.</param>
/// <param name="giftCode">The unique redemption code.</param>
/// <returns>A tuple containing the activated gift and the created subscription.</returns>
public async Task<(SnWalletGift Gift, SnWalletSubscription Subscription)> RedeemGiftAsync(
SnAccount redeemer,
string giftCode)
{
var now = SystemClock.Instance.GetCurrentInstant();
// Find and validate the gift
var gift = await db.WalletGifts
.Include(g => g.Coupon) // Include coupon for price calculation
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
if (gift is null)
throw new InvalidOperationException("Gift code not found.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift is not available for redemption.");
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you.");
// Check if redeemer already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new InvalidOperationException("Invalid gift subscription type.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription = await GetSubscriptionAsync(redeemer.Id, subscriptionsInGroup);
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// Check account level requirement
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == redeemer.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.");
}
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration),
Identifier = gift.SubscriptionIdentifier,
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
OrderId = gift.Id.ToString()
},
BasePrice = gift.BasePrice,
CouponId = gift.CouponId,
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
GiftId = gift.Id
};
// Update the gift status
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.Subscription = subscription;
gift.UpdatedAt = now;
// Save both gift and subscription
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Send notification to redeemer
await NotifyGiftRedeemed(gift, subscription, redeemer);
// Send notification to gifter if different from redeemer
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null)
{
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
}
}
return (gift, subscription);
}
/// <summary>
/// Retrieves a gift by its code (for redemption checking).
/// </summary>
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
}
/// <summary>
/// Retrieves gifts purchased by a specific account.
/// Only returns gifts that have been sent or processed (not created/unpaid ones).
/// </summary>
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
{
return await db.WalletGifts
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
public async Task<SnWalletGift> MarkGiftAsSentAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift cannot be marked as sent.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
public async Task<SnWalletGift> CancelGiftAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created && gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift cannot be cancelled.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Cancelled;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
private async Task<string> GenerateUniqueGiftCodeAsync()
{
const int maxAttempts = 10;
const int codeLength = 12;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
// Generate a random code
var code = GenerateRandomCode(codeLength);
// Check if it already exists
var existingGift = await db.WalletGifts.FirstOrDefaultAsync(g => g.GiftCode == code);
if (existingGift is null)
return code;
}
throw new InvalidOperationException("Unable to generate unique gift code.");
}
private static string GenerateRandomCode(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[Random.Shared.Next(chars.Length)];
}
return new string(result);
}
private async Task NotifyGiftRedeemed(SnWalletGift gift, SnWalletSubscription subscription, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(redeemer);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.redeemed",
Title = localizer["GiftRedeemedTitle"],
Body = localizer["GiftRedeemedBody", humanReadableName],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = redeemer.Id.ToString(),
Notification = notification
}
);
}
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(gifter);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name ?? redeemer.Id.ToString()],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString(),
["redeemer_id"] = redeemer.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = gifter.Id.ToString(),
Notification = notification
}
);
}
}