diff --git a/DysonNetwork.Sphere/Publisher/Publisher.cs b/DysonNetwork.Sphere/Publisher/Publisher.cs index 84efcf5..49f8faa 100644 --- a/DysonNetwork.Sphere/Publisher/Publisher.cs +++ b/DysonNetwork.Sphere/Publisher/Publisher.cs @@ -66,7 +66,7 @@ public class PublisherMember : ModelBase public Instant? JoinedAt { get; set; } } -public enum SubscriptionStatus +public enum PublisherSubscriptionStatus { Active, Expired, @@ -82,7 +82,7 @@ public class PublisherSubscription : ModelBase public Guid AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; - public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active; + public PublisherSubscriptionStatus Status { get; set; } = PublisherSubscriptionStatus.Active; public int Tier { get; set; } = 0; } diff --git a/DysonNetwork.Sphere/Publisher/PublisherService.cs b/DysonNetwork.Sphere/Publisher/PublisherService.cs index 867680c..e9c8e04 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherService.cs @@ -102,7 +102,7 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic // If not in cache, fetch from a database var publishersId = await db.PublisherSubscriptions .Where(p => p.AccountId == userId) - .Where(p => p.Status == SubscriptionStatus.Active) + .Where(p => p.Status == PublisherSubscriptionStatus.Active) .Select(p => p.PublisherId) .ToListAsync(); publishers = await db.Publishers diff --git a/DysonNetwork.Sphere/Publisher/PublisherSubscriptionService.cs b/DysonNetwork.Sphere/Publisher/PublisherSubscriptionService.cs index 27303d5..9475d16 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherSubscriptionService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherSubscriptionService.cs @@ -26,7 +26,7 @@ public class PublisherSubscriptionService( return await db.PublisherSubscriptions .AnyAsync(ps => ps.AccountId == accountId && ps.PublisherId == publisherId && - ps.Status == SubscriptionStatus.Active); + ps.Status == PublisherSubscriptionStatus.Active); } /// @@ -52,7 +52,7 @@ public class PublisherSubscriptionService( var subscribers = await db.PublisherSubscriptions .Include(p => p.Account) .Where(p => p.PublisherId == post.PublisherId && - p.Status == SubscriptionStatus.Active) + p.Status == PublisherSubscriptionStatus.Active) .ToListAsync(); if (subscribers.Count == 0) return 0; @@ -105,7 +105,7 @@ public class PublisherSubscriptionService( { return await db.PublisherSubscriptions .Include(ps => ps.Publisher) - .Where(ps => ps.AccountId == accountId && ps.Status == SubscriptionStatus.Active) + .Where(ps => ps.AccountId == accountId && ps.Status == PublisherSubscriptionStatus.Active) .ToListAsync(); } @@ -118,7 +118,7 @@ public class PublisherSubscriptionService( { return await db.PublisherSubscriptions .Include(ps => ps.Account) - .Where(ps => ps.PublisherId == publisherId && ps.Status == SubscriptionStatus.Active) + .Where(ps => ps.PublisherId == publisherId && ps.Status == PublisherSubscriptionStatus.Active) .ToListAsync(); } @@ -141,8 +141,8 @@ public class PublisherSubscriptionService( if (existingSubscription != null) { // If it exists but is not active, reactivate it - if (existingSubscription.Status == SubscriptionStatus.Active) return existingSubscription; - existingSubscription.Status = SubscriptionStatus.Active; + if (existingSubscription.Status == PublisherSubscriptionStatus.Active) return existingSubscription; + existingSubscription.Status = PublisherSubscriptionStatus.Active; existingSubscription.Tier = tier; await db.SaveChangesAsync(); @@ -156,7 +156,7 @@ public class PublisherSubscriptionService( { AccountId = accountId, PublisherId = publisherId, - Status = SubscriptionStatus.Active, + Status = PublisherSubscriptionStatus.Active, Tier = tier, }; @@ -177,10 +177,10 @@ public class PublisherSubscriptionService( public async Task CancelSubscriptionAsync(Guid accountId, Guid publisherId) { var subscription = await GetSubscriptionAsync(accountId, publisherId); - if (subscription is not { Status: SubscriptionStatus.Active }) + if (subscription is not { Status: PublisherSubscriptionStatus.Active }) return false; - subscription.Status = SubscriptionStatus.Cancelled; + subscription.Status = PublisherSubscriptionStatus.Cancelled; await db.SaveChangesAsync(); await cache.RemoveAsync(string.Format(PublisherService.SubscribedPublishersCacheKey, accountId)); diff --git a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs index 3a5c6fc..f90b41b 100644 --- a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs +++ b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs @@ -1,5 +1,6 @@ using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage.Handlers; +using DysonNetwork.Sphere.Wallet; using Quartz; namespace DysonNetwork.Sphere.Startup; @@ -64,6 +65,16 @@ public static class ScheduledJobsConfiguration .WithIntervalInMinutes(1) .RepeatForever()) ); + + var subscriptionRenewalJob = new JobKey("SubscriptionRenewal"); + q.AddJob(opts => opts.WithIdentity(subscriptionRenewalJob)); + q.AddTrigger(opts => opts + .ForJob(subscriptionRenewalJob) + .WithIdentity("SubscriptionRenewalTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(30) + .RepeatForever()) + ); }); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); diff --git a/DysonNetwork.Sphere/Wallet/OrderController.cs b/DysonNetwork.Sphere/Wallet/OrderController.cs new file mode 100644 index 0000000..a2cf8a9 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/OrderController.cs @@ -0,0 +1,57 @@ +using DysonNetwork.Sphere.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Sphere.Wallet; + +[ApiController] +[Route("/orders")] +public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task> GetOrderById(Guid id) + { + var order = await db.PaymentOrders.FindAsync(id); + + if (order == null) + { + return NotFound(); + } + + return Ok(order); + } + + [HttpPost("{id:guid}/pay")] + [Authorize] + public async Task> PayOrder(Guid id, [FromBody] PayOrderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + // Validate PIN code + if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) + return StatusCode(403, "Invalid PIN Code"); + + try + { + // Get the wallet for the current user + var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id); + if (wallet == null) + return BadRequest("Wallet was not found."); + + // Pay the order + var paidOrder = await payment.PayOrderAsync(id, wallet.Id); + return Ok(paidOrder); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} + +public class PayOrderRequest +{ + public string PinCode { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Wallet/PaymentService.cs b/DysonNetwork.Sphere/Wallet/PaymentService.cs index c51cf94..477c342 100644 --- a/DysonNetwork.Sphere/Wallet/PaymentService.cs +++ b/DysonNetwork.Sphere/Wallet/PaymentService.cs @@ -12,9 +12,37 @@ public class PaymentService(AppDatabase db, WalletService wat) decimal amount, Duration? expiration = null, string? appIdentifier = null, - Dictionary? meta = null + Dictionary? meta = null, + bool reuseable = true ) { + // Check if there's an existing unpaid order that can be reused + if (reuseable && appIdentifier != null) + { + var existingOrder = await db.PaymentOrders + .Where(o => o.Status == OrderStatus.Unpaid && + o.PayeeWalletId == payeeWalletId && + o.Currency == currency && + o.Amount == amount && + o.AppIdentifier == appIdentifier && + o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) + .FirstOrDefaultAsync(); + + // If an existing order is found, check if meta matches + if (existingOrder != null && meta != null && existingOrder.Meta != null) + { + // Compare meta dictionaries - if they are equivalent, reuse the order + var metaMatches = existingOrder.Meta.Count == meta.Count && + !existingOrder.Meta.Except(meta).Any(); + + if (metaMatches) + { + return existingOrder; + } + } + } + + // Create a new order if no reusable order was found var order = new Order { PayeeWalletId = payeeWalletId, diff --git a/DysonNetwork.Sphere/Wallet/Subscription.cs b/DysonNetwork.Sphere/Wallet/Subscription.cs index 9000bfc..d06935c 100644 --- a/DysonNetwork.Sphere/Wallet/Subscription.cs +++ b/DysonNetwork.Sphere/Wallet/Subscription.cs @@ -127,6 +127,7 @@ public class Subscription : ModelBase { get { + if (IsFreeTrial) return 0; if (Coupon == null) return BasePrice; var now = SystemClock.Instance.GetCurrentInstant(); diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionController.cs b/DysonNetwork.Sphere/Wallet/SubscriptionController.cs index fb0dc43..da55eff 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionController.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionController.cs @@ -61,7 +61,10 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba [HttpPost] [Authorize] - public async Task> CreateSubscription([FromBody] CreateSubscriptionRequest request) + public async Task> CreateSubscription( + [FromBody] CreateSubscriptionRequest request, + [FromHeader(Name = "X-Noop")] bool noop = false + ) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); @@ -81,7 +84,8 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba cycleDuration, request.Coupon, request.IsFreeTrial, - request.IsAutoRenewal + request.IsAutoRenewal, + noop ); return subscription; diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs new file mode 100644 index 0000000..fd54867 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs @@ -0,0 +1,123 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Sphere.Wallet; + +public class SubscriptionRenewalJob( + AppDatabase db, + SubscriptionService subscriptionService, + PaymentService paymentService, + WalletService walletService, + ILogger logger +) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + logger.LogInformation("Starting subscription auto-renewal job..."); + + // First update expired subscriptions + var expiredCount = await subscriptionService.UpdateExpiredSubscriptionsAsync(); + logger.LogInformation("Updated {ExpiredCount} expired subscriptions", expiredCount); + + var now = SystemClock.Instance.GetCurrentInstant(); + const int batchSize = 100; // Process in smaller batches + var processedCount = 0; + var renewedCount = 0; + var failedCount = 0; + + // Find subscriptions that need renewal (due for renewal and are still active) + var subscriptionsToRenew = await db.WalletSubscriptions + .Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal + .Where(s => s.Status == SubscriptionStatus.Paid) // Only paid subscriptions + .Where(s => s.IsActive) // Only active subscriptions + .Where(s => !s.IsFreeTrial) // Exclude free trials + .OrderBy(s => s.RenewalAt) // Process oldest first + .Take(batchSize) + .Include(s => s.Coupon) // Include coupon information + .ToListAsync(); + + var totalSubscriptions = subscriptionsToRenew.Count; + logger.LogInformation("Found {TotalSubscriptions} subscriptions due for renewal", totalSubscriptions); + + foreach (var subscription in subscriptionsToRenew) + { + try + { + processedCount++; + logger.LogDebug("Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}", + subscription.Id, subscription.Identifier, subscription.AccountId); + + // Calculate next cycle duration based on current cycle + var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt; + + // Create an order for the renewal payment + var order = await paymentService.CreateOrderAsync( + null, + WalletCurrency.GoldenPoint, + subscription.FinalPrice, + appIdentifier: SubscriptionService.SubscriptionOrderIdentifier, + meta: new Dictionary() + { + ["subscription_id"] = subscription.Id.ToString(), + ["subscription_identifier"] = subscription.Identifier, + ["is_renewal"] = true + } + ); + + // Try to process the payment automatically + if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet) + { + try + { + var wallet = await walletService.GetWalletAsync(subscription.AccountId); + if (wallet is null) continue; + + // Process automatic payment from wallet + await paymentService.PayOrderAsync(order.Id, wallet.Id); + + // Update subscription details + subscription.BegunAt = subscription.EndedAt!.Value; + subscription.EndedAt = subscription.BegunAt.Plus(currentCycle); + subscription.RenewalAt = subscription.EndedAt; + + db.WalletSubscriptions.Update(subscription); + await db.SaveChangesAsync(); + + renewedCount++; + logger.LogInformation("Successfully renewed subscription {SubscriptionId}", subscription.Id); + } + catch (Exception ex) + { + // If auto-payment fails, mark for manual payment + logger.LogWarning(ex, "Failed to auto-renew subscription {SubscriptionId} with wallet payment", subscription.Id); + failedCount++; + } + } + else + { + // For other payment methods, mark as pending payment + logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}", + subscription.Id, subscription.PaymentMethod); + failedCount++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing subscription {SubscriptionId}", subscription.Id); + failedCount++; + } + + // Log progress periodically + if (processedCount % 20 == 0 || processedCount == totalSubscriptions) + { + logger.LogInformation( + "Progress: processed {ProcessedCount}/{TotalSubscriptions} subscriptions, {RenewedCount} renewed, {FailedCount} failed", + processedCount, totalSubscriptions, renewedCount, failedCount); + } + } + + logger.LogInformation("Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}", + processedCount, renewedCount, failedCount); + } +} diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs index c6f8bb9..2e0b19c 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs @@ -13,8 +13,9 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS PaymentDetails paymentDetails, Duration? cycleDuration = null, string? coupon = null, - bool isFreeTrail = false, - bool isAutoRenewal = true + bool isFreeTrial = false, + bool isAutoRenewal = true, + bool noop = false ) { var subscriptionTemplate = SubscriptionTypeData @@ -27,9 +28,18 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS cycleDuration ??= Duration.FromDays(30); var existingSubscription = await GetSubscriptionAsync(account.Id, identifier); - if (existingSubscription is not null) - { + 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; @@ -49,14 +59,14 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS EndedAt = now.Plus(cycleDuration.Value), Identifier = identifier, IsActive = true, - IsFreeTrial = isFreeTrail, + IsFreeTrial = isFreeTrial, Status = SubscriptionStatus.Unpaid, PaymentMethod = paymentMethod, PaymentDetails = paymentDetails, BasePrice = subscriptionTemplate.BasePrice, CouponId = couponData?.Id, Coupon = couponData, - RenewalAt = (isFreeTrail || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), + RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), AccountId = account.Id, }; @@ -156,10 +166,51 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS 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())); + } 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 const string SubscriptionCacheKeyPrefix = "subscription:"; public async Task GetSubscriptionAsync(Guid accountId, string identifier)