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;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user