✨ Subscription service
This commit is contained in:
parent
516090a5f8
commit
9fd6016308
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")
|
||||||
|
@ -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>();
|
||||||
|
@ -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,23 @@ 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
|
||||||
|
)
|
||||||
{
|
{
|
||||||
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>
|
||||||
|
155
DysonNetwork.Sphere/Wallet/SubscriptionController.cs
Normal file
155
DysonNetwork.Sphere/Wallet/SubscriptionController.cs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,191 @@
|
|||||||
|
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 isFreeTrail = false,
|
||||||
|
bool isAutoRenewal = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Active subscription with identifier {identifier} 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 = isFreeTrail,
|
||||||
|
Status = SubscriptionStatus.Unpaid,
|
||||||
|
PaymentMethod = paymentMethod,
|
||||||
|
PaymentDetails = paymentDetails,
|
||||||
|
BasePrice = subscriptionTemplate.BasePrice,
|
||||||
|
CouponId = couponData?.Id,
|
||||||
|
Coupon = couponData,
|
||||||
|
RenewalAt = (isFreeTrail || !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();
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
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