Subscription and stellar program

This commit is contained in:
LittleSheep 2025-06-22 17:57:19 +08:00
parent 9fd6016308
commit 698442ad13
10 changed files with 296 additions and 21 deletions

View File

@ -66,7 +66,7 @@ public class PublisherMember : ModelBase
public Instant? JoinedAt { get; set; } public Instant? JoinedAt { get; set; }
} }
public enum SubscriptionStatus public enum PublisherSubscriptionStatus
{ {
Active, Active,
Expired, Expired,
@ -82,7 +82,7 @@ public class PublisherSubscription : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [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; public int Tier { get; set; } = 0;
} }

View File

@ -102,7 +102,7 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic
// If not in cache, fetch from a database // If not in cache, fetch from a database
var publishersId = await db.PublisherSubscriptions var publishersId = await db.PublisherSubscriptions
.Where(p => p.AccountId == userId) .Where(p => p.AccountId == userId)
.Where(p => p.Status == SubscriptionStatus.Active) .Where(p => p.Status == PublisherSubscriptionStatus.Active)
.Select(p => p.PublisherId) .Select(p => p.PublisherId)
.ToListAsync(); .ToListAsync();
publishers = await db.Publishers publishers = await db.Publishers

View File

@ -26,7 +26,7 @@ public class PublisherSubscriptionService(
return await db.PublisherSubscriptions return await db.PublisherSubscriptions
.AnyAsync(ps => ps.AccountId == accountId && .AnyAsync(ps => ps.AccountId == accountId &&
ps.PublisherId == publisherId && ps.PublisherId == publisherId &&
ps.Status == SubscriptionStatus.Active); ps.Status == PublisherSubscriptionStatus.Active);
} }
/// <summary> /// <summary>
@ -52,7 +52,7 @@ public class PublisherSubscriptionService(
var subscribers = await db.PublisherSubscriptions var subscribers = await db.PublisherSubscriptions
.Include(p => p.Account) .Include(p => p.Account)
.Where(p => p.PublisherId == post.PublisherId && .Where(p => p.PublisherId == post.PublisherId &&
p.Status == SubscriptionStatus.Active) p.Status == PublisherSubscriptionStatus.Active)
.ToListAsync(); .ToListAsync();
if (subscribers.Count == 0) if (subscribers.Count == 0)
return 0; return 0;
@ -105,7 +105,7 @@ public class PublisherSubscriptionService(
{ {
return await db.PublisherSubscriptions return await db.PublisherSubscriptions
.Include(ps => ps.Publisher) .Include(ps => ps.Publisher)
.Where(ps => ps.AccountId == accountId && ps.Status == SubscriptionStatus.Active) .Where(ps => ps.AccountId == accountId && ps.Status == PublisherSubscriptionStatus.Active)
.ToListAsync(); .ToListAsync();
} }
@ -118,7 +118,7 @@ public class PublisherSubscriptionService(
{ {
return await db.PublisherSubscriptions return await db.PublisherSubscriptions
.Include(ps => ps.Account) .Include(ps => ps.Account)
.Where(ps => ps.PublisherId == publisherId && ps.Status == SubscriptionStatus.Active) .Where(ps => ps.PublisherId == publisherId && ps.Status == PublisherSubscriptionStatus.Active)
.ToListAsync(); .ToListAsync();
} }
@ -141,8 +141,8 @@ public class PublisherSubscriptionService(
if (existingSubscription != null) if (existingSubscription != null)
{ {
// If it exists but is not active, reactivate it // If it exists but is not active, reactivate it
if (existingSubscription.Status == SubscriptionStatus.Active) return existingSubscription; if (existingSubscription.Status == PublisherSubscriptionStatus.Active) return existingSubscription;
existingSubscription.Status = SubscriptionStatus.Active; existingSubscription.Status = PublisherSubscriptionStatus.Active;
existingSubscription.Tier = tier; existingSubscription.Tier = tier;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -156,7 +156,7 @@ public class PublisherSubscriptionService(
{ {
AccountId = accountId, AccountId = accountId,
PublisherId = publisherId, PublisherId = publisherId,
Status = SubscriptionStatus.Active, Status = PublisherSubscriptionStatus.Active,
Tier = tier, Tier = tier,
}; };
@ -177,10 +177,10 @@ public class PublisherSubscriptionService(
public async Task<bool> CancelSubscriptionAsync(Guid accountId, Guid publisherId) public async Task<bool> CancelSubscriptionAsync(Guid accountId, Guid publisherId)
{ {
var subscription = await GetSubscriptionAsync(accountId, publisherId); var subscription = await GetSubscriptionAsync(accountId, publisherId);
if (subscription is not { Status: SubscriptionStatus.Active }) if (subscription is not { Status: PublisherSubscriptionStatus.Active })
return false; return false;
subscription.Status = SubscriptionStatus.Cancelled; subscription.Status = PublisherSubscriptionStatus.Cancelled;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await cache.RemoveAsync(string.Format(PublisherService.SubscribedPublishersCacheKey, accountId)); await cache.RemoveAsync(string.Format(PublisherService.SubscribedPublishersCacheKey, accountId));

View File

@ -1,5 +1,6 @@
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers; using DysonNetwork.Sphere.Storage.Handlers;
using DysonNetwork.Sphere.Wallet;
using Quartz; using Quartz;
namespace DysonNetwork.Sphere.Startup; namespace DysonNetwork.Sphere.Startup;
@ -64,6 +65,16 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(1) .WithIntervalInMinutes(1)
.RepeatForever()) .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); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View 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;
}

View File

@ -12,9 +12,37 @@ public class PaymentService(AppDatabase db, WalletService wat)
decimal amount, decimal amount,
Duration? expiration = null, Duration? expiration = null,
string? appIdentifier = null, string? appIdentifier = null,
Dictionary<string, object>? meta = 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 var order = new Order
{ {
PayeeWalletId = payeeWalletId, PayeeWalletId = payeeWalletId,

View File

@ -127,6 +127,7 @@ public class Subscription : ModelBase
{ {
get get
{ {
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice; if (Coupon == null) return BasePrice;
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();

View File

@ -61,7 +61,10 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba
[HttpPost] [HttpPost]
[Authorize] [Authorize]
public async Task<ActionResult<Subscription>> CreateSubscription([FromBody] CreateSubscriptionRequest request) 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(); if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -81,7 +84,8 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba
cycleDuration, cycleDuration,
request.Coupon, request.Coupon,
request.IsFreeTrial, request.IsFreeTrial,
request.IsAutoRenewal request.IsAutoRenewal,
noop
); );
return subscription; return subscription;

View 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);
}
}

View File

@ -13,8 +13,9 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
PaymentDetails paymentDetails, PaymentDetails paymentDetails,
Duration? cycleDuration = null, Duration? cycleDuration = null,
string? coupon = null, string? coupon = null,
bool isFreeTrail = false, bool isFreeTrial = false,
bool isAutoRenewal = true bool isAutoRenewal = true,
bool noop = false
) )
{ {
var subscriptionTemplate = SubscriptionTypeData var subscriptionTemplate = SubscriptionTypeData
@ -27,9 +28,18 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
cycleDuration ??= Duration.FromDays(30); cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, identifier); 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."); 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; Coupon? couponData = null;
@ -49,14 +59,14 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
EndedAt = now.Plus(cycleDuration.Value), EndedAt = now.Plus(cycleDuration.Value),
Identifier = identifier, Identifier = identifier,
IsActive = true, IsActive = true,
IsFreeTrial = isFreeTrail, IsFreeTrial = isFreeTrial,
Status = SubscriptionStatus.Unpaid, Status = SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod, PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails, PaymentDetails = paymentDetails,
BasePrice = subscriptionTemplate.BasePrice, BasePrice = subscriptionTemplate.BasePrice,
CouponId = couponData?.Id, CouponId = couponData?.Id,
Coupon = couponData, Coupon = couponData,
RenewalAt = (isFreeTrail || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
AccountId = account.Id, AccountId = account.Id,
}; };
@ -157,9 +167,50 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
db.Update(subscription); db.Update(subscription);
await db.SaveChangesAsync(); 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; 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:"; private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier) public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier)