using System.Security.Cryptography; using System.Text; using System.Text.Json; using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; using Duration = NodaTime.Duration; namespace DysonNetwork.Pass.Wallet; public class SubscriptionService( AppDatabase db, PaymentService payment, Account.AccountService accounts, RingService.RingServiceClient pusher, IStringLocalizer localizer, IConfiguration configuration, ICacheService cache, ILogger logger ) { public async Task CreateSubscriptionAsync( SnAccount account, string identifier, string paymentMethod, SnPaymentDetails paymentDetails, Duration? cycleDuration = null, string? coupon = null, bool isFreeTrial = false, bool isAutoRenewal = true, bool noop = false ) { var subscriptionInfo = SubscriptionTypeData .SubscriptionDict.TryGetValue(identifier, out var template) ? template : 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) .Select(s => s.Value.Identifier) .ToArray() : [identifier]; cycleDuration ??= Duration.FromDays(30); var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup); if (existingSubscription is not null && !noop) throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists."); 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) { 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 && prevFreeTrial != null) throw new InvalidOperationException("Free trial already exists."); if (coupon != null && couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found."); var now = SystemClock.Instance.GetCurrentInstant(); var subscription = new SnWalletSubscription { BegunAt = now, EndedAt = now.Plus(cycleDuration.Value), Identifier = identifier, IsActive = true, IsFreeTrial = isFreeTrial, Status = Shared.Models.SubscriptionStatus.Unpaid, PaymentMethod = paymentMethod, PaymentDetails = paymentDetails, BasePrice = subscriptionInfo.BasePrice, CouponId = couponData?.Id, Coupon = couponData, RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), AccountId = account.Id, }; db.WalletSubscriptions.Add(subscription); await db.SaveChangesAsync(); return subscription; } public async Task CreateSubscriptionFromOrder(ISubscriptionOrder order) { var cfgSection = configuration.GetSection("Payment:Subscriptions"); var provider = order.Provider; var currency = "irl"; var subscriptionIdentifier = order.SubscriptionId; switch (provider) { case "afdian": // Get the Afdian section first, then bind it to a dictionary var afdianPlans = cfgSection.GetSection("Afdian").Get>(); logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans)); if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName)) subscriptionIdentifier = planName; currency = "cny"; break; } var subscriptionTemplate = SubscriptionTypeData .SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template) ? template : null; if (subscriptionTemplate is null) throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier), $@"Subscription {subscriptionIdentifier} was not found."); SnAccount? account = null; if (!string.IsNullOrEmpty(provider)) account = await accounts.LookupAccountByConnection(order.AccountId, provider); else if (Guid.TryParse(order.AccountId, out var accountId)) account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); if (account is null) throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}"); var cycleDuration = order.Duration; var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier); if (existingSubscription is not null && existingSubscription.PaymentMethod != provider) throw new InvalidOperationException( $"Active subscription with identifier {subscriptionIdentifier} already exists."); if (existingSubscription?.PaymentDetails.OrderId == order.Id) return existingSubscription; if (existingSubscription is not null) { // Same provider, but different order, renew the subscription existingSubscription.PaymentDetails.OrderId = order.Id; existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration); existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration); existingSubscription.Status = Shared.Models.SubscriptionStatus.Active; db.Update(existingSubscription); await db.SaveChangesAsync(); return existingSubscription; } var subscription = new SnWalletSubscription { BegunAt = order.BegunAt, EndedAt = order.BegunAt.Plus(cycleDuration), IsActive = true, Status = Shared.Models.SubscriptionStatus.Active, Identifier = subscriptionIdentifier, PaymentMethod = provider, PaymentDetails = new Shared.Models.SnPaymentDetails { Currency = currency, OrderId = order.Id, }, BasePrice = subscriptionTemplate.BasePrice, RenewalAt = order.BegunAt.Plus(cycleDuration), AccountId = account.Id, }; db.WalletSubscriptions.Add(subscription); await db.SaveChangesAsync(); await NotifySubscriptionBegun(subscription); return subscription; } /// /// Cancel the renewal of the current activated subscription. /// /// The user who requested the action. /// The subscription identifier /// /// The active subscription was not found public async Task CancelSubscriptionAsync(Guid accountId, string identifier) { var subscription = await GetSubscriptionAsync(accountId, identifier); if (subscription is null) throw new InvalidOperationException($"Subscription with identifier {identifier} was not found."); if (subscription.Status != Shared.Models.SubscriptionStatus.Active) throw new InvalidOperationException("Subscription is already cancelled."); if (subscription.RenewalAt is null) throw new InvalidOperationException("Subscription is no need to be cancelled."); if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet) throw new InvalidOperationException( "Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider." ); subscription.RenewalAt = null; await db.SaveChangesAsync(); // Invalidate the cache for this subscription var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}"; await cache.RemoveAsync(cacheKey); return subscription; } /// /// Creates a subscription order for an unpaid or expired subscription. /// If the subscription is active, it will extend its expiration date. /// /// The unique identifier for the account associated with the subscription. /// The unique subscription identifier. /// A task that represents the asynchronous operation. The task result contains the created subscription order. /// Thrown when no matching unpaid or expired subscription is found. public async Task CreateSubscriptionOrder(Guid accountId, string identifier) { var subscription = await db.WalletSubscriptions .Where(s => s.AccountId == accountId && s.Identifier == identifier) .Where(s => s.Status != Shared.Models.SubscriptionStatus.Expired) .Include(s => s.Coupon) .OrderByDescending(s => s.BegunAt) .FirstOrDefaultAsync(); if (subscription is null) throw new InvalidOperationException("No matching subscription found."); var subscriptionInfo = SubscriptionTypeData.SubscriptionDict .TryGetValue(subscription.Identifier, out var template) ? template : null; if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); return await payment.CreateOrderAsync( null, subscriptionInfo.Currency, subscription.FinalPrice, appIdentifier: "internal", productIdentifier: identifier, meta: new Dictionary() { ["subscription_id"] = subscription.Id.ToString(), ["subscription_identifier"] = subscription.Identifier, } ); } /// /// Creates a gift order for an unpaid gift. /// /// The account ID of the gifter. /// The unique identifier for the gift. /// A task that represents the asynchronous operation. The task result contains the created gift order. /// Thrown when the gift is not found or not in payable status. public async Task 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() { ["gift_id"] = gift.Id.ToString() } ); } public async Task HandleSubscriptionOrder(SnWalletOrder order) { if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) throw new InvalidOperationException("Invalid order."); var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId) ? parsedSubscriptionId : Guid.Empty; if (subscriptionId == Guid.Empty) throw new InvalidOperationException("Invalid order."); var subscription = await db.WalletSubscriptions .Where(s => s.Id == subscriptionId) .Include(s => s.Coupon) .FirstOrDefaultAsync(); if (subscription is null) throw new InvalidOperationException("Invalid order."); if (subscription.Status == Shared.Models.SubscriptionStatus.Expired) { // Calculate original cycle duration and extend from the current ended date Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt; 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; db.Update(subscription); await db.SaveChangesAsync(); await NotifySubscriptionBegun(subscription); return subscription; } public async Task 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; } /// /// Updates the status of expired subscriptions to reflect their current state. /// This helps maintain accurate subscription records and is typically called periodically. /// /// Maximum number of subscriptions to process /// Number of subscriptions that were marked as expired public async Task UpdateExpiredSubscriptionsAsync(int batchSize = 100) { var now = SystemClock.Instance.GetCurrentInstant(); // Find active subscriptions that have passed their end date var expiredSubscriptions = await db.WalletSubscriptions .Where(s => s.IsActive) .Where(s => s.Status == Shared.Models.SubscriptionStatus.Active) .Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now) .Take(batchSize) .ToListAsync(); if (expiredSubscriptions.Count == 0) return 0; // Mark as expired foreach (var subscription in expiredSubscriptions) { subscription.Status = Shared.Models.SubscriptionStatus.Expired; } 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; } private async Task NotifySubscriptionBegun(SnWalletSubscription subscription) { var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId); if (account is null) return; Account.AccountService.SetCultureInfo(account); var humanReadableName = SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable) ? humanReadable : subscription.Identifier; var duration = subscription.EndedAt is not null ? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString() : "infinite"; var notification = new PushNotification { Topic = "subscriptions.begun", Title = localizer["SubscriptionAppliedTitle", humanReadableName], Body = localizer["SubscriptionAppliedBody", duration, humanReadableName], Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary { ["subscription_id"] = subscription.Id.ToString() }), IsSavable = true }; await pusher.SendPushNotificationToUserAsync( new SendPushNotificationToUserRequest { UserId = account.Id.ToString(), Notification = notification } ); } private const string SubscriptionCacheKeyPrefix = "subscription:"; public async Task GetSubscriptionAsync(Guid accountId, params string[] identifiers) { // Create a unique cache key for this subscription var identifierPart = identifiers.Length == 1 ? identifiers[0] : Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers)))); var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}"; // Try to get the subscription from cache first var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); if (found && cachedSubscription != null) { return cachedSubscription; } // If not in cache, get from database var subscription = await db.WalletSubscriptions .Where(s => s.AccountId == accountId && identifiers.Contains(s.Identifier)) .OrderByDescending(s => s.BegunAt) .FirstOrDefaultAsync(); if (subscription is { IsAvailable: false }) subscription = null; // Cache the result if found (with 5 minutes expiry) await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(5)); return subscription; } private const string SubscriptionPerkCacheKeyPrefix = "subscription:perk:"; private static readonly List PerkIdentifiers = [SubscriptionType.Stellar, SubscriptionType.Nova, SubscriptionType.Supernova]; public async Task GetPerkSubscriptionAsync(Guid accountId) { var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}"; // Try to get the subscription from cache first var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); if (found && cachedSubscription != null) { return cachedSubscription; } // If not in cache, get from database var now = SystemClock.Instance.GetCurrentInstant(); var subscription = await db.WalletSubscriptions .Where(s => s.AccountId == accountId && PerkIdentifiers.Contains(s.Identifier)) .Where(s => s.Status == Shared.Models.SubscriptionStatus.Active) .Where(s => s.EndedAt == null || s.EndedAt > now) .OrderByDescending(s => s.BegunAt) .FirstOrDefaultAsync(); if (subscription is { IsAvailable: false }) subscription = null; // Cache the result if found (with 5 minutes expiry) await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(5)); return subscription; } public async Task> GetPerkSubscriptionsAsync(List accountIds) { var result = new Dictionary(); var missingAccountIds = new List(); // Try to get the subscription from cache first var cacheTasks = accountIds.Select(async accountId => { var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}"; var (found, cachedSubscription) = await cache.GetAsyncWithStatus(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 not in cache, get from database var now = SystemClock.Instance.GetCurrentInstant(); var subscriptions = await db.WalletSubscriptions .Where(s => missingAccountIds.Contains(s.AccountId)) .Where(s => PerkIdentifiers.Contains(s.Identifier)) .Where(s => s.Status == Shared.Models.SubscriptionStatus.Active) .Where(s => s.EndedAt == null || s.EndedAt > now) .OrderByDescending(s => s.BegunAt) .ToListAsync(); // 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(); foreach (var kvp in groupedSubscriptions) { 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; } /// /// Purchases a gift subscription that can be redeemed by another user. /// /// The account purchasing the gift. /// Optional specific recipient. If null, creates an open gift anyone can redeem. /// The subscription type being gifted. /// Payment method used by the gifter. /// Payment details from the gifter. /// Optional personal message from the gifter. /// Optional coupon code for discount. /// How long the gift can be redeemed (default 30 days). /// The duration of the subscription once redeemed (default 30 days). /// The created gift record. public async Task 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 { ["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; } /// /// Activates a gift using the redemption code, creating a subscription for the redeemer. /// /// The account redeeming the gift. /// The unique redemption code. /// A tuple containing the activated gift and the created subscription. 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); } /// /// Retrieves a gift by its code (for redemption checking). /// public async Task 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); } /// /// Retrieves gifts purchased by a specific account. /// Only returns gifts that have been sent or processed (not created/unpaid ones). /// public async Task> 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> 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(); } /// /// Marks a gift as sent (ready for redemption). /// public async Task 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; } /// /// Cancels a gift before it's redeemed. /// public async Task 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 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 { ["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 { ["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 } ); } }