Compare commits

...

3 Commits

Author SHA1 Message Date
698442ad13 Subscription and stellar program 2025-06-22 17:57:19 +08:00
9fd6016308 Subscription service 2025-06-22 03:15:16 +08:00
516090a5f8 🐛 Fixes afdian service 2025-06-22 02:35:12 +08:00
16 changed files with 4417 additions and 43 deletions

View File

@ -32,7 +32,7 @@ public class AfdianOidcService(
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://afdian.com/oauth2/authorize?{queryString}"; return $"https://afdian.com/oauth2/authorize?{queryString}";
} }
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{ {
return Task.FromResult(new OidcDiscoveryDocument return Task.FromResult(new OidcDiscoveryDocument
@ -43,7 +43,7 @@ public class AfdianOidcService(
JwksUri = null JwksUri = null
})!; })!;
} }
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{ {
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
@ -60,8 +60,10 @@ public class AfdianOidcService(
return userInfo; return userInfo;
} }
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(
string? codeVerifier = null) string code,
string? codeVerifier = null
)
{ {
var config = GetProviderConfig(); var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient(); var client = HttpClientFactory.CreateClient();
@ -78,37 +80,44 @@ public class AfdianOidcService(
var response = await client.PostAsync("https://afdian.com/api/oauth2/access_token", content); var response = await client.PostAsync("https://afdian.com/api/oauth2/access_token", content);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); return new OidcTokenResponse()
{
AccessToken = code,
ExpiresIn = 3600
};
} }
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) 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 client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/oauth2/access_token");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(); 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 userId = afdianUser.GetProperty("user_id").GetString() ?? "";
var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; var avatar = afdianUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo return new OidcUserInfo
{ {
UserId = userId, UserId = userId,
Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", DisplayName = (afdianUser.TryGetProperty("name", out var nameElement)
EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && ? nameElement.GetString()
verifiedElement.GetBoolean(),
DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement)
? globalNameElement.GetString()
: null) ?? "", : null) ?? "",
PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", ProfilePictureUrl = avatar,
ProfilePictureUrl = !string.IsNullOrEmpty(avatar)
? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"
: "",
Provider = ProviderName Provider = ProviderName
}; };
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2504,6 +2504,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("numeric") .HasColumnType("numeric")
.HasColumnName("amount"); .HasColumnName("amount");
b.Property<string>("AppIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("app_identifier");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@ -2526,7 +2531,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("issuer_app_id"); .HasColumnName("issuer_app_id");
b.Property<Guid>("PayeeWalletId") b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("payee_wallet_id"); .HasColumnName("payee_wallet_id");
@ -3418,8 +3427,6 @@ namespace DysonNetwork.Sphere.Migrations
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet") b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
.WithMany() .WithMany()
.HasForeignKey("PayeeWalletId") .HasForeignKey("PayeeWalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_payment_orders_wallets_payee_wallet_id"); .HasConstraintName("fk_payment_orders_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction") b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction")

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

@ -205,8 +205,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<MagicSpellService>(); services.AddScoped<MagicSpellService>();
services.AddScoped<NotificationService>(); services.AddScoped<NotificationService>();
services.AddScoped<AuthService>(); services.AddScoped<AuthService>();
services.AddScoped<AppleOidcService>();
services.AddScoped<GoogleOidcService>();
services.AddScoped<AccountUsernameService>(); services.AddScoped<AccountUsernameService>();
services.AddScoped<FileService>(); services.AddScoped<FileService>();
services.AddScoped<FileReferenceService>(); services.AddScoped<FileReferenceService>();
@ -220,6 +218,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ChatService>(); services.AddScoped<ChatService>();
services.AddScoped<StickerService>(); services.AddScoped<StickerService>();
services.AddScoped<WalletService>(); services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>(); services.AddScoped<PaymentService>();
services.AddScoped<IRealtimeService, LivekitRealtimeService>(); services.AddScoped<IRealtimeService, LivekitRealtimeService>();
services.AddScoped<WebReaderService>(); services.AddScoped<WebReaderService>();

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

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
using NodaTime; using NodaTime;
@ -7,6 +8,7 @@ namespace DysonNetwork.Sphere.Wallet;
public class WalletCurrency public class WalletCurrency
{ {
public const string SourcePoint = "points"; public const string SourcePoint = "points";
public const string GoldenPoint = "golds";
} }
public enum OrderStatus public enum OrderStatus
@ -24,11 +26,13 @@ public class Order : ModelBase
public OrderStatus Status { get; set; } = OrderStatus.Unpaid; public OrderStatus Status { get; set; } = OrderStatus.Unpaid;
[MaxLength(128)] public string Currency { get; set; } = null!; [MaxLength(128)] public string Currency { get; set; } = null!;
[MaxLength(4096)] public string? Remarks { get; set; } [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 decimal Amount { get; set; }
public Instant ExpiredAt { get; set; } public Instant ExpiredAt { get; set; }
public Guid PayeeWalletId { get; set; } public Guid? PayeeWalletId { get; set; }
public Wallet PayeeWallet { get; set; } = null!; public Wallet? PayeeWallet { get; set; } = null!;
public Guid? TransactionId { get; set; } public Guid? TransactionId { get; set; }
public Transaction? Transaction { get; set; } public Transaction? Transaction { get; set; }
public Guid? IssuerAppId { get; set; } public Guid? IssuerAppId { get; set; }

View File

@ -6,14 +6,51 @@ namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(AppDatabase db, WalletService wat) 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 var order = new Order
{ {
PayeeWalletId = payeeWalletId, PayeeWalletId = payeeWalletId,
Currency = currency, Currency = currency,
Amount = amount, 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); db.PaymentOrders.Add(order);

View File

@ -5,6 +5,21 @@ using NodaTime;
namespace DysonNetwork.Sphere.Wallet; 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 public abstract class SubscriptionType
{ {
/// <summary> /// <summary>
@ -12,7 +27,7 @@ public abstract class SubscriptionType
/// this is the prefix of all the stellar program subscriptions. /// this is the prefix of all the stellar program subscriptions.
/// </summary> /// </summary>
public const string StellarProgram = "solian.stellar"; public const string StellarProgram = "solian.stellar";
/// <summary> /// <summary>
/// No actual usage, just tells there is a free level named twinkle. /// 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. /// 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 bool IsFreeTrial { get; set; }
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid; public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid;
[MaxLength(4096)] public string PaymentMethod { get; set; } = null!; [MaxLength(4096)] public string PaymentMethod { get; set; } = null!;
[Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!; [Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!;
public decimal BasePrice { get; set; } public decimal BasePrice { get; set; }
@ -112,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

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

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

@ -1,5 +1,242 @@
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet; 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;
}
} }