Compare commits
	
		
			3 Commits
		
	
	
		
			6b0e5f919d
			...
			698442ad13
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 698442ad13 | |||
| 9fd6016308 | |||
| 516090a5f8 | 
| @@ -60,8 +60,10 @@ public class AfdianOidcService( | ||||
|         return userInfo; | ||||
|     } | ||||
|  | ||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, | ||||
|         string? codeVerifier = null) | ||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync( | ||||
|         string code, | ||||
|         string? codeVerifier = null | ||||
|     ) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var client = HttpClientFactory.CreateClient(); | ||||
| @@ -78,37 +80,44 @@ public class AfdianOidcService( | ||||
|         var response = await client.PostAsync("https://afdian.com/api/oauth2/access_token", content); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); | ||||
|         return new OidcTokenResponse() | ||||
|         { | ||||
|             AccessToken = code, | ||||
|             ExpiresIn = 3600 | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) | ||||
|     { | ||||
|         var config = GetProviderConfig(); | ||||
|         var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||||
|         { | ||||
|             { "client_id", config.ClientId }, | ||||
|             { "client_secret", config.ClientSecret }, | ||||
|             { "grant_type", "authorization_code" }, | ||||
|             { "code", accessToken }, | ||||
|             { "redirect_uri", config.RedirectUri }, | ||||
|         }); | ||||
|          | ||||
|         var client = HttpClientFactory.CreateClient(); | ||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); | ||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); | ||||
|         var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/oauth2/access_token"); | ||||
|  | ||||
|         var response = await client.SendAsync(request); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         var json = await response.Content.ReadAsStringAsync(); | ||||
|         var discordUser = JsonDocument.Parse(json).RootElement; | ||||
|         var afdianUser = JsonDocument.Parse(json).RootElement; | ||||
|  | ||||
|         var userId = discordUser.GetProperty("id").GetString() ?? ""; | ||||
|         var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; | ||||
|         var userId = afdianUser.GetProperty("user_id").GetString() ?? ""; | ||||
|         var avatar = afdianUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; | ||||
|  | ||||
|         return new OidcUserInfo | ||||
|         { | ||||
|             UserId = userId, | ||||
|             Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", | ||||
|             EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && | ||||
|                             verifiedElement.GetBoolean(), | ||||
|             DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement) | ||||
|                 ? globalNameElement.GetString() | ||||
|             DisplayName = (afdianUser.TryGetProperty("name", out var nameElement) | ||||
|                 ? nameElement.GetString() | ||||
|                 : null) ?? "", | ||||
|             PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", | ||||
|             ProfilePictureUrl = !string.IsNullOrEmpty(avatar) | ||||
|                 ? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png" | ||||
|                 : "", | ||||
|             ProfilePictureUrl = avatar, | ||||
|             Provider = ProviderName | ||||
|         }; | ||||
|     } | ||||
|   | ||||
							
								
								
									
										3633
									
								
								DysonNetwork.Sphere/Migrations/20250621191505_WalletOrderAppDX.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3633
									
								
								DysonNetwork.Sphere/Migrations/20250621191505_WalletOrderAppDX.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,82 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class WalletOrderAppDX : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropForeignKey( | ||||
|                 name: "fk_payment_orders_wallets_payee_wallet_id", | ||||
|                 table: "payment_orders"); | ||||
|  | ||||
|             migrationBuilder.AlterColumn<Guid>( | ||||
|                 name: "payee_wallet_id", | ||||
|                 table: "payment_orders", | ||||
|                 type: "uuid", | ||||
|                 nullable: true, | ||||
|                 oldClrType: typeof(Guid), | ||||
|                 oldType: "uuid"); | ||||
|  | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "app_identifier", | ||||
|                 table: "payment_orders", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddColumn<Dictionary<string, object>>( | ||||
|                 name: "meta", | ||||
|                 table: "payment_orders", | ||||
|                 type: "jsonb", | ||||
|                 nullable: true); | ||||
|  | ||||
|             migrationBuilder.AddForeignKey( | ||||
|                 name: "fk_payment_orders_wallets_payee_wallet_id", | ||||
|                 table: "payment_orders", | ||||
|                 column: "payee_wallet_id", | ||||
|                 principalTable: "wallets", | ||||
|                 principalColumn: "id"); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropForeignKey( | ||||
|                 name: "fk_payment_orders_wallets_payee_wallet_id", | ||||
|                 table: "payment_orders"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "app_identifier", | ||||
|                 table: "payment_orders"); | ||||
|  | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "meta", | ||||
|                 table: "payment_orders"); | ||||
|  | ||||
|             migrationBuilder.AlterColumn<Guid>( | ||||
|                 name: "payee_wallet_id", | ||||
|                 table: "payment_orders", | ||||
|                 type: "uuid", | ||||
|                 nullable: false, | ||||
|                 defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), | ||||
|                 oldClrType: typeof(Guid), | ||||
|                 oldType: "uuid", | ||||
|                 oldNullable: true); | ||||
|  | ||||
|             migrationBuilder.AddForeignKey( | ||||
|                 name: "fk_payment_orders_wallets_payee_wallet_id", | ||||
|                 table: "payment_orders", | ||||
|                 column: "payee_wallet_id", | ||||
|                 principalTable: "wallets", | ||||
|                 principalColumn: "id", | ||||
|                 onDelete: ReferentialAction.Cascade); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -2504,6 +2504,11 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                         .HasColumnType("numeric") | ||||
|                         .HasColumnName("amount"); | ||||
|  | ||||
|                     b.Property<string>("AppIdentifier") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("app_identifier"); | ||||
|  | ||||
|                     b.Property<Instant>("CreatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("created_at"); | ||||
| @@ -2526,7 +2531,11 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("issuer_app_id"); | ||||
|  | ||||
|                     b.Property<Guid>("PayeeWalletId") | ||||
|                     b.Property<Dictionary<string, object>>("Meta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("meta"); | ||||
|  | ||||
|                     b.Property<Guid?>("PayeeWalletId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("payee_wallet_id"); | ||||
|  | ||||
| @@ -3418,8 +3427,6 @@ namespace DysonNetwork.Sphere.Migrations | ||||
|                     b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("PayeeWalletId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_payment_orders_wallets_payee_wallet_id"); | ||||
|  | ||||
|                     b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction") | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
| @@ -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<bool> 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)); | ||||
|   | ||||
| @@ -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<SubscriptionRenewalJob>(opts => opts.WithIdentity(subscriptionRenewalJob)); | ||||
|             q.AddTrigger(opts => opts | ||||
|                 .ForJob(subscriptionRenewalJob) | ||||
|                 .WithIdentity("SubscriptionRenewalTrigger") | ||||
|                 .WithSimpleSchedule(o => o | ||||
|                     .WithIntervalInMinutes(30) | ||||
|                     .RepeatForever()) | ||||
|             ); | ||||
|         }); | ||||
|         services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); | ||||
|  | ||||
|   | ||||
| @@ -205,8 +205,6 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<MagicSpellService>(); | ||||
|         services.AddScoped<NotificationService>(); | ||||
|         services.AddScoped<AuthService>(); | ||||
|         services.AddScoped<AppleOidcService>(); | ||||
|         services.AddScoped<GoogleOidcService>(); | ||||
|         services.AddScoped<AccountUsernameService>(); | ||||
|         services.AddScoped<FileService>(); | ||||
|         services.AddScoped<FileReferenceService>(); | ||||
| @@ -220,6 +218,7 @@ public static class ServiceCollectionExtensions | ||||
|         services.AddScoped<ChatService>(); | ||||
|         services.AddScoped<StickerService>(); | ||||
|         services.AddScoped<WalletService>(); | ||||
|         services.AddScoped<SubscriptionService>(); | ||||
|         services.AddScoped<PaymentService>(); | ||||
|         services.AddScoped<IRealtimeService, LivekitRealtimeService>(); | ||||
|         services.AddScoped<WebReaderService>(); | ||||
|   | ||||
							
								
								
									
										57
									
								
								DysonNetwork.Sphere/Wallet/OrderController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								DysonNetwork.Sphere/Wallet/OrderController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<ActionResult<Order>> 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<ActionResult<Order>> 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; | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| using NodaTime; | ||||
|  | ||||
| @@ -7,6 +8,7 @@ namespace DysonNetwork.Sphere.Wallet; | ||||
| public class WalletCurrency | ||||
| { | ||||
|     public const string SourcePoint = "points"; | ||||
|     public const string GoldenPoint = "golds"; | ||||
| } | ||||
|  | ||||
| public enum OrderStatus | ||||
| @@ -24,11 +26,13 @@ public class Order : ModelBase | ||||
|     public OrderStatus Status { get; set; } = OrderStatus.Unpaid; | ||||
|     [MaxLength(128)] public string Currency { get; set; } = null!; | ||||
|     [MaxLength(4096)] public string? Remarks { get; set; } | ||||
|     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } | ||||
|     public decimal Amount { get; set; } | ||||
|     public Instant ExpiredAt { get; set; } | ||||
|      | ||||
|     public Guid PayeeWalletId { get; set; } | ||||
|     public Wallet PayeeWallet { get; set; } = null!; | ||||
|     public Guid? PayeeWalletId { get; set; } | ||||
|     public Wallet? PayeeWallet { get; set; } = null!; | ||||
|     public Guid? TransactionId { get; set; } | ||||
|     public Transaction? Transaction { get; set; } | ||||
|     public Guid? IssuerAppId { get; set; } | ||||
|   | ||||
| @@ -6,14 +6,51 @@ namespace DysonNetwork.Sphere.Wallet; | ||||
|  | ||||
| public class PaymentService(AppDatabase db, WalletService wat) | ||||
| { | ||||
|     public async Task<Order> CreateOrderAsync(Guid payeeWalletId, string currency, decimal amount, Duration expiration) | ||||
|     public async Task<Order> CreateOrderAsync( | ||||
|         Guid? payeeWalletId, | ||||
|         string currency, | ||||
|         decimal amount, | ||||
|         Duration? expiration = null, | ||||
|         string? appIdentifier = null, | ||||
|         Dictionary<string, object>? 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, | ||||
|             Currency = currency, | ||||
|             Amount = amount, | ||||
|             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration) | ||||
|             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), | ||||
|             AppIdentifier = appIdentifier, | ||||
|             Meta = meta | ||||
|         }; | ||||
|  | ||||
|         db.PaymentOrders.Add(order); | ||||
|   | ||||
| @@ -5,6 +5,21 @@ using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Wallet; | ||||
|  | ||||
| public record class SubscriptionTypeData( | ||||
|     string Identifier, | ||||
|     decimal BasePrice | ||||
| ) | ||||
| { | ||||
|     public static Dictionary<string, SubscriptionTypeData> SubscriptionDict = | ||||
|         new() | ||||
|         { | ||||
|             [SubscriptionType.Twinkle] = new SubscriptionTypeData(SubscriptionType.Twinkle, 0), | ||||
|             [SubscriptionType.Stellar] = new SubscriptionTypeData(SubscriptionType.Stellar, 10), | ||||
|             [SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20), | ||||
|             [SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30) | ||||
|         }; | ||||
| } | ||||
|  | ||||
| public abstract class SubscriptionType | ||||
| { | ||||
|     /// <summary> | ||||
| @@ -112,6 +127,7 @@ public class Subscription : ModelBase | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             if (IsFreeTrial) return 0; | ||||
|             if (Coupon == null) return BasePrice; | ||||
|  | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|   | ||||
							
								
								
									
										159
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Wallet; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/subscriptions")] | ||||
| public class SubscriptionController(SubscriptionService subscriptions, AppDatabase db) : ControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<List<Subscription>>> ListSubscriptions( | ||||
|         [FromQuery] int offset = 0, | ||||
|         [FromQuery] int take = 20 | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var query = db.WalletSubscriptions.AsQueryable() | ||||
|             .Where(s => s.AccountId == currentUser.Id) | ||||
|             .Include(s => s.Coupon) | ||||
|             .OrderByDescending(s => s.BegunAt); | ||||
|  | ||||
|         var totalCount = await query.CountAsync(); | ||||
|  | ||||
|         var subscriptionsList = await query | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         Response.Headers["X-Total"] = totalCount.ToString(); | ||||
|  | ||||
|         return subscriptionsList; | ||||
|     } | ||||
|  | ||||
|     [HttpGet("{identifier}")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Subscription>> GetSubscription(string identifier) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var subscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, identifier); | ||||
|         if (subscription is null) return NotFound($"Subscription with identifier {identifier} was not found."); | ||||
|  | ||||
|         return subscription; | ||||
|     } | ||||
|  | ||||
|     public class CreateSubscriptionRequest | ||||
|     { | ||||
|         [Required] public string Identifier { get; set; } = null!; | ||||
|         [Required] public string PaymentMethod { get; set; } = null!; | ||||
|         [Required] public PaymentDetails PaymentDetails { get; set; } = null!; | ||||
|         public string? Coupon { get; set; } | ||||
|         public int? CycleDurationDays { get; set; } | ||||
|         public bool IsFreeTrial { get; set; } = false; | ||||
|         public bool IsAutoRenewal { get; set; } = true; | ||||
|     } | ||||
|  | ||||
|     [HttpPost] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Subscription>> CreateSubscription( | ||||
|         [FromBody] CreateSubscriptionRequest request, | ||||
|         [FromHeader(Name = "X-Noop")] bool noop = false | ||||
|     ) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         Duration? cycleDuration = null; | ||||
|         if (request.CycleDurationDays.HasValue) | ||||
|         { | ||||
|             cycleDuration = Duration.FromDays(request.CycleDurationDays.Value); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var subscription = await subscriptions.CreateSubscriptionAsync( | ||||
|                 currentUser, | ||||
|                 request.Identifier, | ||||
|                 request.PaymentMethod, | ||||
|                 request.PaymentDetails, | ||||
|                 cycleDuration, | ||||
|                 request.Coupon, | ||||
|                 request.IsFreeTrial, | ||||
|                 request.IsAutoRenewal, | ||||
|                 noop | ||||
|             ); | ||||
|  | ||||
|             return subscription; | ||||
|         } | ||||
|         catch (ArgumentOutOfRangeException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{identifier}/cancel")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Subscription>> CancelSubscription(string identifier) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var subscription = await subscriptions.CancelSubscriptionAsync(currentUser.Id, identifier); | ||||
|             return subscription; | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{identifier}/order")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Order>> CreateSubscriptionOrder(string identifier) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var order = await subscriptions.CreateSubscriptionOrder(currentUser.Id, identifier); | ||||
|             return order; | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class SubscriptionOrderRequest | ||||
|     { | ||||
|         [Required] public string OrderId { get; set; } = null!; | ||||
|     } | ||||
|  | ||||
|     [HttpPost("order/handle")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<Subscription>> HandleSubscriptionOrder([FromBody] SubscriptionOrderRequest request) | ||||
|     { | ||||
|         var order = await db.PaymentOrders.FindAsync(request.OrderId); | ||||
|         if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var subscription = await subscriptions.HandleSubscriptionOrder(order); | ||||
|             return subscription; | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										123
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<SubscriptionRenewalJob> 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<string, object>() | ||||
|                     { | ||||
|                         ["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); | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,242 @@ | ||||
| using DysonNetwork.Sphere.Storage; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Wallet; | ||||
|  | ||||
| public class SubscriptionService(AppDatabase db) | ||||
| public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheService cache) | ||||
| { | ||||
|     public async Task<Subscription> 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; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Cancel the renewal of the current activated subscription. | ||||
|     /// </summary> | ||||
|     /// <param name="accountId">The user who requested the action.</param> | ||||
|     /// <param name="identifier">The subscription identifier</param> | ||||
|     /// <returns></returns> | ||||
|     /// <exception cref="InvalidOperationException">The active subscription was not found</exception> | ||||
|     public async Task<Subscription> 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"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a subscription order for an unpaid or expired subscription. | ||||
|     /// If the subscription is active, it will extend its expiration date. | ||||
|     /// </summary> | ||||
|     /// <param name="accountId">The unique identifier for the account associated with the subscription.</param> | ||||
|     /// <param name="identifier">The unique subscription identifier.</param> | ||||
|     /// <returns>A task that represents the asynchronous operation. The task result contains the created subscription order.</returns> | ||||
|     /// <exception cref="InvalidOperationException">Thrown when no matching unpaid or expired subscription is found.</exception> | ||||
|     public async Task<Order> 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<string, object>() | ||||
|             { | ||||
|                 ["subscription_id"] = subscription.Id.ToString(), | ||||
|                 ["subscription_identifier"] = subscription.Identifier, | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public async Task<Subscription> HandleSubscriptionOrder(Order order) | ||||
|     { | ||||
|         if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid || | ||||
|             order.Meta?["subscription_id"] is not string subscriptionId) | ||||
|             throw new InvalidOperationException("Invalid order."); | ||||
|  | ||||
|         var subscriptionIdParsed = Guid.TryParse(subscriptionId, out var parsedSubscriptionId) | ||||
|             ? parsedSubscriptionId | ||||
|             : Guid.Empty; | ||||
|         if (subscriptionIdParsed == Guid.Empty) | ||||
|             throw new InvalidOperationException("Invalid order."); | ||||
|         var subscription = await db.WalletSubscriptions | ||||
|             .Where(s => s.Id == subscriptionIdParsed) | ||||
|             .Include(s => s.Coupon) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (subscription is null) | ||||
|             throw new InvalidOperationException("Invalid order."); | ||||
|  | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? now); | ||||
|  | ||||
|         var nextRenewalAt = subscription.RenewalAt?.Plus(cycle); | ||||
|         var nextEndedAt = subscription.RenewalAt?.Plus(cycle); | ||||
|  | ||||
|         subscription.Status = SubscriptionStatus.Paid; | ||||
|         subscription.RenewalAt = nextRenewalAt; | ||||
|         subscription.EndedAt = nextEndedAt; | ||||
|  | ||||
|         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; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Updates the status of expired subscriptions to reflect their current state. | ||||
|     /// This helps maintain accurate subscription records and is typically called periodically. | ||||
|     /// </summary> | ||||
|     /// <param name="batchSize">Maximum number of subscriptions to process</param> | ||||
|     /// <returns>Number of subscriptions that were marked as expired</returns> | ||||
|     public async Task<int> 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<Subscription?> 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<Subscription>(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; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user