♻️ Basically completed the separate of account service

This commit is contained in:
2025-07-12 11:40:18 +08:00
parent e76c80eead
commit ba49d1c7a7
69 changed files with 4245 additions and 225 deletions

View File

@@ -0,0 +1,57 @@
using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/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 AuthSession currentSession) return Unauthorized();
// Validate PIN code
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
return StatusCode(403, "Invalid PIN Code");
try
{
// Get the wallet for the current user
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
if (wallet == null)
return BadRequest("Wallet was not found.");
// Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet.Id);
return Ok(paidOrder);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
public class PayOrderRequest
{
public string PinCode { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
public class WalletCurrency
{
public const string SourcePoint = "points";
public const string GoldenPoint = "golds";
}
public enum OrderStatus
{
Unpaid,
Paid,
Cancelled,
Finished,
Expired
}
public class Order : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
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? TransactionId { get; set; }
public Transaction? Transaction { get; set; }
}
public enum TransactionType
{
System,
Transfer,
Order
}
public class Transaction : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
[MaxLength(4096)] public string? Remarks { get; set; }
public TransactionType Type { get; set; }
// When the payer is null, it's pay from the system
public Guid? PayerWalletId { get; set; }
public Wallet? PayerWallet { get; set; }
// When the payee is null, it's pay for the system
public Guid? PayeeWalletId { get; set; }
public Wallet? PayeeWallet { get; set; }
}

View File

@@ -0,0 +1,446 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
public class AfdianPaymentHandler(
IHttpClientFactory httpClientFactory,
ILogger<AfdianPaymentHandler> logger,
IConfiguration configuration
)
{
private readonly IHttpClientFactory _httpClientFactory =
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
private readonly ILogger<AfdianPaymentHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IConfiguration _configuration =
configuration ?? throw new ArgumentNullException(nameof(configuration));
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private string CalculateSign(string token, string userId, string paramsJson, long ts)
{
var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}";
using (var md5 = MD5.Create())
{
var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString));
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
}
}
public async Task<OrderResponse?> ListOrderAsync(int page = 1)
{
try
{
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { page }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return null;
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching orders");
throw;
}
}
/// <summary>
/// Get a specific order by its ID (out_trade_no)
/// </summary>
/// <param name="orderId">The order ID to query</param>
/// <returns>The order item if found, otherwise null</returns>
public async Task<OrderItem?> GetOrderAsync(string orderId)
{
if (string.IsNullOrEmpty(orderId))
{
_logger.LogWarning("Order ID cannot be null or empty");
return null;
}
try
{
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderId }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return null;
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
// Check if we have a valid response and orders in the list
if (result?.Data.Orders == null || result.Data.Orders.Count == 0)
{
_logger.LogWarning($"No order found with ID: {orderId}");
return null;
}
// Since we're querying by a specific order ID, we should only get one result
return result.Data.Orders.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching order with ID: {orderId}");
throw;
}
}
/// <summary>
/// Get multiple orders by their IDs (out_trade_no)
/// </summary>
/// <param name="orderIds">A collection of order IDs to query</param>
/// <returns>A list of found order items</returns>
public async Task<List<OrderItem>> GetOrderBatchAsync(IEnumerable<string> orderIds)
{
var orders = orderIds.ToList();
if (orders.Count == 0)
{
_logger.LogWarning("Order IDs cannot be null or empty");
return [];
}
try
{
// Join the order IDs with commas as specified in the API documentation
var orderIdsParam = string.Join(",", orders);
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
var tokenParts = token.Split(':');
var userId = tokenParts[0];
token = tokenParts[1];
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderIdsParam }, JsonOptions);
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
.TotalSeconds; // Current timestamp in seconds
var sign = CalculateSign(token, userId, paramsJson, ts);
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
{
Content = new StringContent(JsonSerializer.Serialize(new
{
user_id = userId,
@params = paramsJson,
ts,
sign
}, JsonOptions), Encoding.UTF8, "application/json")
};
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
return new List<OrderItem>();
}
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
await response.Content.ReadAsStreamAsync(), JsonOptions);
// Check if we have a valid response and orders in the list
if (result?.Data?.Orders != null && result.Data.Orders.Count != 0) return result.Data.Orders;
_logger.LogWarning($"No orders found with IDs: {orderIdsParam}");
return [];
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error fetching orders");
throw;
}
}
/// <summary>
/// Handle an incoming webhook from Afdian's payment platform
/// </summary>
/// <param name="request">The HTTP request containing webhook data</param>
/// <param name="processOrderAction">An action to process the received order</param>
/// <returns>A WebhookResponse object to be returned to Afdian</returns>
public async Task<WebhookResponse> HandleWebhook(
HttpRequest request,
Func<WebhookOrderData, Task>? processOrderAction
)
{
_logger.LogInformation("Received webhook request from afdian...");
try
{
// Read the request body
string requestBody;
using (var reader = new StreamReader(request.Body, Encoding.UTF8))
{
requestBody = await reader.ReadToEndAsync();
}
if (string.IsNullOrEmpty(requestBody))
{
_logger.LogError("Webhook request body is empty");
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" };
}
_logger.LogInformation($"Received webhook: {requestBody}");
// Parse the webhook data
var webhook = JsonSerializer.Deserialize<WebhookRequest>(requestBody, JsonOptions);
if (webhook == null)
{
_logger.LogError("Failed to parse webhook data");
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" };
}
// Validate the webhook type
if (webhook.Data.Type != "order")
{
_logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}");
return WebhookResponse.Success;
}
// Process the order
try
{
// Check for duplicate order processing by storing processed order IDs
// (You would implement a more permanent storage mechanism for production)
if (processOrderAction != null)
await processOrderAction(webhook.Data);
else
_logger.LogInformation(
$"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}");
// Still returning success to Afdian to prevent repeated callbacks
// Your system should handle the error internally
}
// Return success response to Afdian
return WebhookResponse.Success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling webhook");
return WebhookResponse.Success;
}
}
public string? GetSubscriptionPlanId(string subscriptionKey)
{
var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"];
if (string.IsNullOrEmpty(planId))
{
_logger.LogWarning($"Unknown subscription key: {subscriptionKey}");
return null;
}
return planId;
}
}
public class OrderResponse
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
[JsonPropertyName("data")] public OrderData Data { get; set; } = null!;
}
public class OrderData
{
[JsonPropertyName("list")] public List<OrderItem> Orders { get; set; } = null!;
[JsonPropertyName("total_count")] public int TotalCount { get; set; }
[JsonPropertyName("total_page")] public int TotalPages { get; set; }
[JsonPropertyName("request")] public RequestDetails Request { get; set; } = null!;
}
public class OrderItem : ISubscriptionOrder
{
[JsonPropertyName("out_trade_no")] public string TradeNumber { get; set; } = null!;
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
[JsonPropertyName("plan_id")] public string PlanId { get; set; } = null!;
[JsonPropertyName("month")] public int Months { get; set; }
[JsonPropertyName("total_amount")] public string TotalAmount { get; set; } = null!;
[JsonPropertyName("show_amount")] public string ShowAmount { get; set; } = null!;
[JsonPropertyName("status")] public int Status { get; set; }
[JsonPropertyName("remark")] public string Remark { get; set; } = null!;
[JsonPropertyName("redeem_id")] public string RedeemId { get; set; } = null!;
[JsonPropertyName("product_type")] public int ProductType { get; set; }
[JsonPropertyName("discount")] public string Discount { get; set; } = null!;
[JsonPropertyName("sku_detail")] public List<object> SkuDetail { get; set; } = null!;
[JsonPropertyName("create_time")] public long CreateTime { get; set; }
[JsonPropertyName("user_name")] public string UserName { get; set; } = null!;
[JsonPropertyName("plan_title")] public string PlanTitle { get; set; } = null!;
[JsonPropertyName("user_private_id")] public string UserPrivateId { get; set; } = null!;
[JsonPropertyName("address_person")] public string AddressPerson { get; set; } = null!;
[JsonPropertyName("address_phone")] public string AddressPhone { get; set; } = null!;
[JsonPropertyName("address_address")] public string AddressAddress { get; set; } = null!;
public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime);
public Duration Duration => Duration.FromDays(Months * 30);
public string Provider => "afdian";
public string Id => TradeNumber;
public string SubscriptionId => PlanId;
public string AccountId => UserId;
}
public class RequestDetails
{
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
[JsonPropertyName("params")] public string Params { get; set; } = null!;
[JsonPropertyName("ts")] public long Timestamp { get; set; }
[JsonPropertyName("sign")] public string Sign { get; set; } = null!;
}
/// <summary>
/// Request structure for Afdian webhook
/// </summary>
public class WebhookRequest
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
[JsonPropertyName("data")] public WebhookOrderData Data { get; set; } = null!;
}
/// <summary>
/// Order data contained in the webhook
/// </summary>
public class WebhookOrderData
{
[JsonPropertyName("type")] public string Type { get; set; } = null!;
[JsonPropertyName("order")] public WebhookOrderDetails Order { get; set; } = null!;
}
/// <summary>
/// Order details in the webhook
/// </summary>
public class WebhookOrderDetails : OrderItem
{
[JsonPropertyName("custom_order_id")] public string CustomOrderId { get; set; } = null!;
}
/// <summary>
/// Response structure to acknowledge webhook receipt
/// </summary>
public class WebhookResponse
{
[JsonPropertyName("ec")] public int ErrorCode { get; set; } = 200;
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = "";
public static WebhookResponse Success => new()
{
ErrorCode = 200,
ErrorMessage = string.Empty
};
}
/// <summary>
/// SKU detail item
/// </summary>
public class SkuDetailItem
{
[JsonPropertyName("sku_id")] public string SkuId { get; set; } = null!;
[JsonPropertyName("count")] public int Count { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = null!;
[JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!;
[JsonPropertyName("pic")] public string Picture { get; set; } = null!;
}

View File

@@ -0,0 +1,18 @@
using NodaTime;
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
public interface ISubscriptionOrder
{
public string Id { get; }
public string SubscriptionId { get; }
public Instant BegunAt { get; }
public Duration Duration { get; }
public string Provider { get; }
public string AccountId { get; }
}

View File

@@ -0,0 +1,297 @@
using System.Globalization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
public class PaymentService(
AppDatabase db,
WalletService wat,
NotificationService nty,
IStringLocalizer<NotificationResource> localizer
)
{
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 ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier,
Meta = meta
};
db.PaymentOrders.Add(order);
await db.SaveChangesAsync();
return order;
}
public async Task<Transaction> CreateTransactionWithAccountAsync(
Guid? payerAccountId,
Guid? payeeAccountId,
string currency,
decimal amount,
string? remarks = null,
TransactionType type = TransactionType.System
)
{
Wallet? payer = null, payee = null;
if (payerAccountId.HasValue)
payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value);
if (payeeAccountId.HasValue)
payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value);
if (payer == null && payerAccountId.HasValue)
throw new ArgumentException("Payer account was specified, but wallet was not found");
if (payee == null && payeeAccountId.HasValue)
throw new ArgumentException("Payee account was specified, but wallet was not found");
return await CreateTransactionAsync(
payer?.Id,
payee?.Id,
currency,
amount,
remarks,
type
);
}
public async Task<Transaction> CreateTransactionAsync(
Guid? payerWalletId,
Guid? payeeWalletId,
string currency,
decimal amount,
string? remarks = null,
TransactionType type = TransactionType.System
)
{
if (payerWalletId == null && payeeWalletId == null)
throw new ArgumentException("At least one wallet must be specified.");
if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount.");
var transaction = new Transaction
{
PayerWalletId = payerWalletId,
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
Remarks = remarks,
Type = type
};
if (payerWalletId.HasValue)
{
var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
if (isNewlyCreated || payerPocket.Amount < amount)
throw new InvalidOperationException("Insufficient funds");
await db.WalletPockets
.Where(p => p.Id == payerPocket.Id && p.Amount >= amount)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Amount, p => p.Amount - amount));
}
if (payeeWalletId.HasValue)
{
var (payeePocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
if (!isNewlyCreated)
await db.WalletPockets
.Where(p => p.Id == payeePocket.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.Amount, p => p.Amount + amount));
}
db.PaymentTransactions.Add(transaction);
await db.SaveChangesAsync();
return transaction;
}
public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Order is in invalid status: {order.Status}");
}
if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant())
{
order.Status = OrderStatus.Expired;
await db.SaveChangesAsync();
throw new InvalidOperationException("Order has expired");
}
var transaction = await CreateTransactionAsync(
payerWalletId,
order.PayeeWalletId,
order.Currency,
order.Amount,
order.Remarks ?? $"Payment for Order #{order.Id}",
type: TransactionType.Order);
order.TransactionId = transaction.Id;
order.Transaction = transaction;
order.Status = OrderStatus.Paid;
await db.SaveChangesAsync();
await NotifyOrderPaid(order);
return order;
}
private async Task NotifyOrderPaid(Order order)
{
if (order.PayeeWallet is null) return;
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId);
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await nty.SendNotification(
account,
"wallets.orders.paid",
localizer["OrderPaidTitle", $"#{readableOrderId}"],
null,
localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark],
new Dictionary<string, object>()
{
["order_id"] = order.Id.ToString()
}
);
}
public async Task<Order> CancelOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders.FindAsync(orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}");
}
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync();
return order;
}
public async Task<(Order Order, Transaction RefundTransaction)> RefundOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Paid)
{
throw new InvalidOperationException($"Cannot refund order in status: {order.Status}");
}
if (order.Transaction == null)
{
throw new InvalidOperationException("Order has no associated transaction");
}
var refundTransaction = await CreateTransactionAsync(
order.PayeeWalletId,
order.Transaction.PayerWalletId,
order.Currency,
order.Amount,
$"Refund for order {order.Id}");
order.Status = OrderStatus.Finished;
await db.SaveChangesAsync();
return (order, refundTransaction);
}
public async Task<Transaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency,
decimal amount)
{
var payerWallet = await wat.GetWalletAsync(payerAccountId);
if (payerWallet == null)
{
throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}");
}
var payeeWallet = await wat.GetWalletAsync(payeeAccountId);
if (payeeWallet == null)
{
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
}
return await CreateTransactionAsync(
payerWallet.Id,
payeeWallet.Id,
currency,
amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}",
TransactionType.Transfer);
}
}

View File

@@ -0,0 +1,233 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
public record class SubscriptionTypeData(
string Identifier,
string? GroupIdentifier,
string Currency,
decimal BasePrice,
int? RequiredLevel = null
)
{
public static readonly Dictionary<string, SubscriptionTypeData> SubscriptionDict =
new()
{
[SubscriptionType.Twinkle] = new SubscriptionTypeData(
SubscriptionType.Twinkle,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
0,
1
),
[SubscriptionType.Stellar] = new SubscriptionTypeData(
SubscriptionType.Stellar,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
1200,
3
),
[SubscriptionType.Nova] = new SubscriptionTypeData(
SubscriptionType.Nova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
2400,
6
),
[SubscriptionType.Supernova] = new SubscriptionTypeData(
SubscriptionType.Supernova,
SubscriptionType.StellarProgram,
WalletCurrency.SourcePoint,
3600,
9
)
};
public static readonly Dictionary<string, string> SubscriptionHumanReadable =
new()
{
[SubscriptionType.Twinkle] = "Stellar Program Twinkle",
[SubscriptionType.Stellar] = "Stellar Program",
[SubscriptionType.Nova] = "Stellar Program Nova",
[SubscriptionType.Supernova] = "Stellar Program Supernova"
};
}
public abstract class SubscriptionType
{
/// <summary>
/// DO NOT USE THIS TYPE DIRECTLY,
/// this is the prefix of all the stellar program subscriptions.
/// </summary>
public const string StellarProgram = "solian.stellar";
/// <summary>
/// No actual usage, just tells there is a free level named twinkle.
/// Applies to every registered user by default, so there is no need to create a record in db for that.
/// </summary>
public const string Twinkle = "solian.stellar.twinkle";
public const string Stellar = "solian.stellar.primary";
public const string Nova = "solian.stellar.nova";
public const string Supernova = "solian.stellar.supernova";
}
public abstract class SubscriptionPaymentMethod
{
/// <summary>
/// The solar points / solar dollars.
/// </summary>
public const string InAppWallet = "solian.wallet";
/// <summary>
/// afdian.com
/// aka. China patreon
/// </summary>
public const string Afdian = "afdian";
}
public enum SubscriptionStatus
{
Unpaid,
Active,
Expired,
Cancelled
}
/// <summary>
/// The subscription is for the Stellar Program in most cases.
/// The paid subscription in another word.
/// </summary>
[Index(nameof(Identifier))]
public class Subscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant BegunAt { get; set; }
public Instant? EndedAt { get; set; }
/// <summary>
/// The type of the subscriptions
/// </summary>
[MaxLength(4096)]
public string Identifier { get; set; } = null!;
/// <summary>
/// The field is used to override the activation status of the membership.
/// Might be used for refund handling and other special cases.
///
/// Go see the IsAvailable field if you want to get real the status of the membership.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Indicates is the current user got the membership for free,
/// to prevent giving the same discount for the same user again.
/// </summary>
public bool IsFreeTrial { get; set; }
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid;
[MaxLength(4096)] public string PaymentMethod { get; set; } = null!;
[Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!;
public decimal BasePrice { get; set; }
public Guid? CouponId { get; set; }
public Coupon? Coupon { get; set; }
public Instant? RenewalAt { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[NotMapped]
public bool IsAvailable
{
get
{
if (!IsActive) return false;
var now = SystemClock.Instance.GetCurrentInstant();
if (BegunAt > now) return false;
if (EndedAt.HasValue && now > EndedAt.Value) return false;
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Active) return false;
return true;
}
}
[NotMapped]
public decimal FinalPrice
{
get
{
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice;
var now = SystemClock.Instance.GetCurrentInstant();
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value ||
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice;
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
return BasePrice;
}
}
}
public class PaymentDetails
{
public string Currency { get; set; } = null!;
public string? OrderId { get; set; }
}
/// <summary>
/// A discount that can applies in purchases among the Solar Network.
/// For now, it can be used in the subscription purchase.
/// </summary>
public class Coupon : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The items that can apply this coupon.
/// Leave it to null to apply to all items.
/// </summary>
[MaxLength(4096)]
public string? Identifier { get; set; }
/// <summary>
/// The code that human-readable and memorizable.
/// Leave it blank to use it only with the ID.
/// </summary>
[MaxLength(1024)]
public string? Code { get; set; }
public Instant? AffectedAt { get; set; }
public Instant? ExpiredAt { get; set; }
/// <summary>
/// The amount of the discount.
/// If this field and the rate field are both not null,
/// the amount discount will be applied and the discount rate will be ignored.
/// Formula: <code>final price = base price - discount amount</code>
/// </summary>
public decimal? DiscountAmount { get; set; }
/// <summary>
/// The percentage of the discount.
/// If this field and the amount field are both not null,
/// this field will be ignored.
/// Formula: <code>final price = base price * (1 - discount rate)</code>
/// </summary>
public double? DiscountRate { get; set; }
/// <summary>
/// The max usage of the current coupon.
/// Leave it to null to use it unlimited.
/// </summary>
public int? MaxUsage { get; set; }
}

View File

@@ -0,0 +1,204 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/subscriptions")]
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, 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("fuzzy/{prefix}")]
[Authorize]
public async Task<ActionResult<Subscription>> GetSubscriptionFuzzy(string prefix)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var subs = await db.WalletSubscriptions
.Where(s => s.AccountId == currentUser.Id && s.IsActive)
.Where(s => EF.Functions.ILike(s.Identifier, prefix + "%"))
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
if (subs.Count == 0) return NotFound();
var subscription = subs.FirstOrDefault(s => s.IsAvailable);
if (subscription is null) return NotFound();
return Ok(subscription);
}
[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 Guid OrderId { get; set; }
}
[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);
}
}
public class RestorePurchaseRequest
{
[Required] public string OrderId { get; set; } = null!;
}
[HttpPost("order/restore/afdian")]
[Authorize]
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
{
var order = await afdian.GetOrderAsync(request.OrderId);
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
return Ok(subscription);
}
[HttpPost("order/handle/afdian")]
public async Task<ActionResult<WebhookResponse>> AfdianWebhook()
{
var response = await afdian.HandleWebhook(Request, async webhookData =>
{
var order = webhookData.Order;
await subscriptions.CreateSubscriptionFromOrder(order);
});
return Ok(response);
}
}

View File

@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.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.Active) // 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);
if (subscription.RenewalAt is null)
{
logger.LogWarning(
"Subscription {SubscriptionId} (Identifier: {Identifier}) has no renewal date or has been cancelled.",
subscription.Id, subscription.Identifier);
subscription.Status = SubscriptionStatus.Cancelled;
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
continue;
}
// Calculate next cycle duration based on current cycle
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;
// Create an order for the renewal payment
var order = await paymentService.CreateOrderAsync(
null,
WalletCurrency.GoldenPoint,
subscription.FinalPrice,
appIdentifier: SubscriptionService.SubscriptionOrderIdentifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
["subscription_identifier"] = subscription.Identifier,
["is_renewal"] = true
}
);
// Try to process the payment automatically
if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
try
{
var wallet = await walletService.GetWalletAsync(subscription.AccountId);
if (wallet is null) continue;
// Process automatic payment from wallet
await paymentService.PayOrderAsync(order.Id, wallet.Id);
// Update subscription details
subscription.BegunAt = subscription.EndedAt!.Value;
subscription.EndedAt = subscription.BegunAt.Plus(currentCycle);
subscription.RenewalAt = subscription.EndedAt;
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
renewedCount++;
logger.LogInformation("Successfully renewed subscription {SubscriptionId}", subscription.Id);
}
catch (Exception ex)
{
// If auto-payment fails, mark for manual payment
logger.LogWarning(ex, "Failed to auto-renew subscription {SubscriptionId} with wallet payment",
subscription.Id);
failedCount++;
}
}
else
{
// For other payment methods, mark as pending payment
logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}",
subscription.Id, subscription.PaymentMethod);
failedCount++;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing subscription {SubscriptionId}", subscription.Id);
failedCount++;
}
// Log progress periodically
if (processedCount % 20 == 0 || processedCount == totalSubscriptions)
{
logger.LogInformation(
"Progress: processed {ProcessedCount}/{TotalSubscriptions} subscriptions, {RenewedCount} renewed, {FailedCount} failed",
processedCount, totalSubscriptions, renewedCount, failedCount);
}
}
logger.LogInformation(
"Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
processedCount, renewedCount, failedCount);
}
}

View File

@@ -0,0 +1,394 @@
using System.Text.Json;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Pass.Wallet;
public class SubscriptionService(
AppDatabase db,
PaymentService payment,
AccountService accounts,
NotificationService nty,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration,
ICacheService cache,
ILogger<SubscriptionService> logger
)
{
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 subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(identifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [identifier];
cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup);
if (existingSubscription is not null && !noop)
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
if (existingSubscription is not null)
return existingSubscription;
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
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 = subscriptionInfo.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;
}
public async Task<Subscription> CreateSubscriptionFromOrder(ISubscriptionOrder order)
{
var cfgSection = configuration.GetSection("Payment:Subscriptions");
var provider = order.Provider;
var currency = "irl";
var subscriptionIdentifier = order.SubscriptionId;
switch (provider)
{
case "afdian":
// Get the Afdian section first, then bind it to a dictionary
var afdianPlans = cfgSection.GetSection("Afdian").Get<Dictionary<string, string>>();
logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans));
if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName))
subscriptionIdentifier = planName;
currency = "cny";
break;
}
var subscriptionTemplate = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionTemplate is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
Account.Account? account = null;
if (!string.IsNullOrEmpty(provider))
account = await accounts.LookupAccountByConnection(order.AccountId, provider);
else if (Guid.TryParse(order.AccountId, out var accountId))
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
if (account is null)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
var cycleDuration = order.Duration;
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
throw new InvalidOperationException(
$"Active subscription with identifier {subscriptionIdentifier} already exists.");
if (existingSubscription?.PaymentDetails.OrderId == order.Id)
return existingSubscription;
if (existingSubscription is not null)
{
// Same provider, but different order, renew the subscription
existingSubscription.PaymentDetails.OrderId = order.Id;
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.Status = SubscriptionStatus.Active;
db.Update(existingSubscription);
await db.SaveChangesAsync();
return existingSubscription;
}
var subscription = new Subscription
{
BegunAt = order.BegunAt,
EndedAt = order.BegunAt.Plus(cycleDuration),
IsActive = true,
Status = SubscriptionStatus.Active,
Identifier = subscriptionIdentifier,
PaymentMethod = provider,
PaymentDetails = new PaymentDetails
{
Currency = currency,
OrderId = order.Id,
},
BasePrice = subscriptionTemplate.BasePrice,
RenewalAt = order.BegunAt.Plus(cycleDuration),
AccountId = account.Id,
};
db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
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.Active)
throw new InvalidOperationException("Subscription is already cancelled.");
if (subscription.RenewalAt is null)
throw new InvalidOperationException("Subscription is no need to be cancelled.");
if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet)
throw new InvalidOperationException(
"Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider."
);
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.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(subscription.Identifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
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 JsonElement subscriptionIdJson)
throw new InvalidOperationException("Invalid order.");
var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId)
? parsedSubscriptionId
: Guid.Empty;
if (subscriptionId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var subscription = await db.WalletSubscriptions
.Where(s => s.Id == subscriptionId)
.Include(s => s.Coupon)
.FirstOrDefaultAsync();
if (subscription is null)
throw new InvalidOperationException("Invalid order.");
if (subscription.Status == SubscriptionStatus.Expired)
{
var now = SystemClock.Instance.GetCurrentInstant();
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
}
subscription.Status = SubscriptionStatus.Active;
db.Update(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
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.Active)
.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 async Task NotifySubscriptionBegun(Subscription subscription)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
if (account is null) return;
AccountService.SetCultureInfo(account);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var duration = subscription.EndedAt is not null
? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString()
: "infinite";
await nty.SendNotification(
account,
"subscriptions.begun",
localizer["SubscriptionAppliedTitle", humanReadableName],
null,
localizer["SubscriptionAppliedBody", duration, humanReadableName],
new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
}
);
}
private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{
// Create a unique cache key for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}";
// 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 && identifiers.Contains(s.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;
}
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
namespace DysonNetwork.Pass.Wallet;
public class Wallet : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
}
public class WalletPocket : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public Guid WalletId { get; set; }
[JsonIgnore] public Wallet Wallet { get; set; } = null!;
}

View File

@@ -0,0 +1,101 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
[ApiController]
[Route("/api/wallets")]
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
{
[HttpPost]
[Authorize]
public async Task<ActionResult<Wallet>> CreateWallet()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
try
{
var wallet = await ws.CreateWalletAsync(currentUser.Id);
return Ok(wallet);
}
catch (Exception err)
{
return BadRequest(err.Message);
}
}
[HttpGet]
[Authorize]
public async Task<ActionResult<Wallet>> GetWallet()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var wallet = await ws.GetWalletAsync(currentUser.Id);
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
return Ok(wallet);
}
[HttpGet("transactions")]
[Authorize]
public async Task<ActionResult<List<Transaction>>> GetTransactions(
[FromQuery] int offset = 0, [FromQuery] int take = 20
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var query = db.PaymentTransactions.AsQueryable()
.Include(t => t.PayeeWallet)
.Include(t => t.PayerWallet)
.Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) ||
(t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id));
var transactionCount = await query.CountAsync();
var transactions = await query
.Skip(offset)
.Take(take)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
Response.Headers["X-Total"] = transactionCount.ToString();
return Ok(transactions);
}
public class WalletBalanceRequest
{
public string? Remark { get; set; }
[Required] public decimal Amount { get; set; }
[Required] public string Currency { get; set; } = null!;
[Required] public Guid AccountId { get; set; }
}
[HttpPost("balance")]
[Authorize]
[RequiredPermission("maintenance", "wallets.balance.modify")]
public async Task<ActionResult<Transaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
{
var wallet = await ws.GetWalletAsync(request.AccountId);
if (wallet is null) return NotFound("Wallet was not found.");
var transaction = request.Amount >= 0
? await payment.CreateTransactionAsync(
payerWalletId: null,
payeeWalletId: wallet.Id,
currency: request.Currency,
amount: request.Amount,
remarks: request.Remark
)
: await payment.CreateTransactionAsync(
payerWalletId: wallet.Id,
payeeWalletId: null,
currency: request.Currency,
amount: request.Amount,
remarks: request.Remark
);
return Ok(transaction);
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Wallet;
public class WalletService(AppDatabase db)
{
public async Task<Wallet?> GetWalletAsync(Guid accountId)
{
return await db.Wallets
.Include(w => w.Pockets)
.FirstOrDefaultAsync(w => w.AccountId == accountId);
}
public async Task<Wallet> CreateWalletAsync(Guid accountId)
{
var existingWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == accountId);
if (existingWallet != null)
{
throw new InvalidOperationException($"Wallet already exists for account {accountId}");
}
var wallet = new Wallet { AccountId = accountId };
db.Wallets.Add(wallet);
await db.SaveChangesAsync();
return wallet;
}
public async Task<(WalletPocket wallet, bool isNewlyCreated)> GetOrCreateWalletPocketAsync(
Guid walletId,
string currency,
decimal? initialAmount = null
)
{
var pocket = await db.WalletPockets.FirstOrDefaultAsync(p => p.Currency == currency && p.WalletId == walletId);
if (pocket != null) return (pocket, false);
pocket = new WalletPocket
{
Currency = currency,
Amount = initialAmount ?? 0,
WalletId = walletId
};
db.WalletPockets.Add(pocket);
return (pocket, true);
}
}