Compare commits
3 Commits
6b0e5f919d
...
698442ad13
Author | SHA1 | Date | |
---|---|---|---|
698442ad13 | |||
9fd6016308 | |||
516090a5f8 |
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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")
|
.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")
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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));
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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>();
|
||||||
|
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;
|
||||||
|
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; }
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
@ -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();
|
||||||
|
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;
|
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