using System.Text.Json; using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Wallet.PaymentHandlers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; namespace DysonNetwork.Sphere.Wallet; public class SubscriptionService( AppDatabase db, PaymentService payment, AccountService accounts, NotificationService nty, IStringLocalizer localizer, IConfiguration configuration, ICacheService cache, ILogger logger ) { public async Task CreateSubscriptionAsync( Account.Account account, string identifier, string paymentMethod, PaymentDetails paymentDetails, Duration? cycleDuration = null, string? coupon = null, bool isFreeTrial = false, bool isAutoRenewal = true, bool noop = false ) { var subscriptionTemplate = SubscriptionTypeData .SubscriptionDict.TryGetValue(identifier, out var template) ? template : null; if (subscriptionTemplate is null) throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found."); cycleDuration ??= Duration.FromDays(30); var existingSubscription = await GetSubscriptionAsync(account.Id, identifier); if (existingSubscription is not null && !noop) throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists."); if (existingSubscription is not null) return existingSubscription; 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."); } Coupon? 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."); } var now = SystemClock.Instance.GetCurrentInstant(); var subscription = new Subscription { BegunAt = now, EndedAt = now.Plus(cycleDuration.Value), Identifier = identifier, IsActive = true, IsFreeTrial = isFreeTrial, Status = SubscriptionStatus.Unpaid, PaymentMethod = paymentMethod, PaymentDetails = paymentDetails, BasePrice = subscriptionTemplate.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."); Account.Account? 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 = SubscriptionStatus.Paid; db.Update(existingSubscription); await db.SaveChangesAsync(); return existingSubscription; } var subscription = new Subscription { BegunAt = order.BegunAt, EndedAt = order.BegunAt.Plus(cycleDuration), IsActive = true, Status = SubscriptionStatus.Paid, Identifier = subscriptionIdentifier, PaymentMethod = provider, PaymentDetails = new PaymentDetails { 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 == SubscriptionStatus.Cancelled) throw new InvalidOperationException("Subscription is already cancelled."); subscription.Status = SubscriptionStatus.Cancelled; subscription.RenewalAt = null; await db.SaveChangesAsync(); // Invalidate the cache for this subscription var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}"; await cache.RemoveAsync(cacheKey); return subscription; } public const string SubscriptionOrderIdentifier = "solian.subscription.order"; /// /// 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 != SubscriptionStatus.Expired) .Include(s => s.Coupon) .OrderByDescending(s => s.BegunAt) .FirstOrDefaultAsync(); if (subscription is null) throw new InvalidOperationException("No matching subscription found."); return await payment.CreateOrderAsync( null, WalletCurrency.GoldenPoint, subscription.FinalPrice, appIdentifier: SubscriptionOrderIdentifier, meta: new Dictionary() { ["subscription_id"] = subscription.Id.ToString(), ["subscription_identifier"] = subscription.Identifier, } ); } public async Task HandleSubscriptionOrder(Order order) { if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != 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 == SubscriptionStatus.Expired) { var now = SystemClock.Instance.GetCurrentInstant(); var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now); var nextRenewalAt = subscription.RenewalAt?.Plus(cycle); var nextEndedAt = subscription.EndedAt?.Plus(cycle); subscription.RenewalAt = nextRenewalAt; subscription.EndedAt = nextEndedAt; } subscription.Status = SubscriptionStatus.Paid; db.Update(subscription); await db.SaveChangesAsync(); if (subscription.Identifier.StartsWith(SubscriptionType.StellarProgram)) { await db.AccountProfiles .Where(a => a.AccountId == subscription.AccountId) .ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference())); } await NotifySubscriptionBegun(subscription); return subscription; } /// /// 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 == SubscriptionStatus.Paid) .Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now) .Take(batchSize) .ToListAsync(); if (expiredSubscriptions.Count == 0) return 0; foreach (var subscription in expiredSubscriptions) { subscription.Status = SubscriptionStatus.Expired; // Clear the cache for this subscription var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"; await cache.RemoveAsync(cacheKey); } await db.SaveChangesAsync(); return expiredSubscriptions.Count; } private async Task NotifySubscriptionBegun(Subscription subscription) { var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId); if (account is null) return; 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).ToString() : "infinite"; await nty.SendNotification( account, "subscriptions.begun", localizer["SubscriptionAppliedTitle", humanReadableName], null, localizer["SubscriptionAppliedBody", duration, humanReadableName], new Dictionary() { ["subscription_id"] = subscription.Id.ToString(), } ); } private const string SubscriptionCacheKeyPrefix = "subscription:"; public async Task GetSubscriptionAsync(Guid accountId, string identifier) { // Create a unique cache key for this subscription var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}"; // 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 && s.Identifier == identifier) .OrderByDescending(s => s.BegunAt) .FirstOrDefaultAsync(); // Cache the result if found (with 30 minutes expiry) if (subscription != null) { await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30)); } return subscription; } }