Subscription and stellar program

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

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,
Duration? expiration = 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
{
PayeeWalletId = payeeWalletId,

View File

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

View File

@ -61,7 +61,10 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba
[HttpPost]
[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();
@ -81,7 +84,8 @@ public class SubscriptionController(SubscriptionService subscriptions, AppDataba
cycleDuration,
request.Coupon,
request.IsFreeTrial,
request.IsAutoRenewal
request.IsAutoRenewal,
noop
);
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,
Duration? cycleDuration = null,
string? coupon = null,
bool isFreeTrail = false,
bool isAutoRenewal = true
bool isFreeTrial = false,
bool isAutoRenewal = true,
bool noop = false
)
{
var subscriptionTemplate = SubscriptionTypeData
@ -27,9 +28,18 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, identifier);
if (existingSubscription is not null)
{
if (existingSubscription is not null && !noop)
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
if (existingSubscription is not null)
return existingSubscription;
if (isFreeTrial)
{
var prevFreeTrial = await db.WalletSubscriptions
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
.FirstOrDefaultAsync();
if (prevFreeTrial is not null)
throw new InvalidOperationException("Free trial already exists.");
}
Coupon? couponData = null;
@ -49,14 +59,14 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
EndedAt = now.Plus(cycleDuration.Value),
Identifier = identifier,
IsActive = true,
IsFreeTrial = isFreeTrail,
IsFreeTrial = isFreeTrial,
Status = SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
BasePrice = subscriptionTemplate.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
RenewalAt = (isFreeTrail || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
AccountId = account.Id,
};
@ -156,10 +166,51 @@ public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheS
db.Update(subscription);
await db.SaveChangesAsync();
if (subscription.Identifier.StartsWith(SubscriptionType.StellarProgram))
{
await db.AccountProfiles
.Where(a => a.AccountId == subscription.AccountId)
.ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference()));
}
return subscription;
}
/// <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)