✨ Subscription service
This commit is contained in:
		| @@ -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,23 @@ 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 | ||||
|     ) | ||||
|     { | ||||
|         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> | ||||
| @@ -12,7 +27,7 @@ public abstract class SubscriptionType | ||||
|     /// this is the prefix of all the stellar program subscriptions. | ||||
|     /// </summary> | ||||
|     public const string StellarProgram = "solian.stellar"; | ||||
|      | ||||
|  | ||||
|     /// <summary> | ||||
|     /// No actual usage, just tells there is a free level named twinkle. | ||||
|     /// Applies to every registered user by default, so there is no need to create a record in db for that. | ||||
| @@ -78,7 +93,7 @@ public class Subscription : ModelBase | ||||
|     public bool IsFreeTrial { get; set; } | ||||
|  | ||||
|     public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid; | ||||
|      | ||||
|  | ||||
|     [MaxLength(4096)] public string PaymentMethod { get; set; } = null!; | ||||
|     [Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!; | ||||
|     public decimal BasePrice { get; set; } | ||||
|   | ||||
							
								
								
									
										155
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								DysonNetwork.Sphere/Wallet/SubscriptionController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| 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) | ||||
|     { | ||||
|         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 | ||||
|             ); | ||||
|  | ||||
|             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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,191 @@ | ||||
| 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 isFreeTrail = false, | ||||
|         bool isAutoRenewal = true | ||||
|     ) | ||||
|     { | ||||
|         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) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Active subscription with identifier {identifier} 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 = isFreeTrail, | ||||
|             Status = SubscriptionStatus.Unpaid, | ||||
|             PaymentMethod = paymentMethod, | ||||
|             PaymentDetails = paymentDetails, | ||||
|             BasePrice = subscriptionTemplate.BasePrice, | ||||
|             CouponId = couponData?.Id, | ||||
|             Coupon = couponData, | ||||
|             RenewalAt = (isFreeTrail || !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(); | ||||
|  | ||||
|         return subscription; | ||||
|     } | ||||
|  | ||||
|     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