Wallet, payment, developer apps, feature flags of publishers

♻️ Simplified the permission check of chat room, realm, publishers
This commit is contained in:
2025-05-15 00:26:15 +08:00
parent 9576870373
commit d7d4fde06a
27 changed files with 7468 additions and 124 deletions

View File

@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
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; }
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,184 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(AppDatabase db, WalletService wat)
{
public async Task<Order> CreateOrderAsync(Guid payeeWalletId, string currency, decimal amount, Duration expiration)
{
var order = new Order
{
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration)
};
db.PaymentOrders.Add(order);
await db.SaveChangesAsync();
return order;
}
public async Task<Transaction> CreateTransactionAsync(
Guid? payerWalletId,
Guid? payeeWalletId,
string currency,
decimal amount,
string? remarks = null,
TransactionType type = TransactionType.System
)
{
var transaction = new Transaction
{
PayerWalletId = payerWalletId,
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
Remarks = remarks,
Type = type
};
if (payerWalletId.HasValue)
{
var payerPocket = await wat.GetOrCreateWalletPocketAsync(
(await db.Wallets.FindAsync(payerWalletId.Value))!.AccountId,
currency);
if (payerPocket.Amount < amount)
{
throw new InvalidOperationException("Insufficient funds");
}
payerPocket.Amount -= amount;
}
if (payeeWalletId.HasValue)
{
var payeeWallet = await db.Wallets.FindAsync(payeeWalletId.Value);
var payeePocket = await wat.GetOrCreateWalletPocketAsync(
payeeWallet!.AccountId,
currency);
payeePocket.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();
return order;
}
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,23 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.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; }
public Wallet Wallet { get; set; } = null!;
}

View File

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.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 wallet = new Wallet
{
AccountId = accountId
};
db.Wallets.Add(wallet);
await db.SaveChangesAsync();
return wallet;
}
public async Task<WalletPocket> GetOrCreateWalletPocketAsync(Guid accountId, string currency)
{
var wallet = await db.Wallets
.Include(w => w.Pockets)
.FirstOrDefaultAsync(w => w.AccountId == accountId);
if (wallet == null)
{
throw new InvalidOperationException($"Wallet not found for account {accountId}");
}
var pocket = wallet.Pockets.FirstOrDefault(p => p.Currency == currency);
if (pocket != null) return pocket;
pocket = new WalletPocket
{
Currency = currency,
Amount = 0,
WalletId = wallet.Id
};
wallet.Pockets.Add(pocket);
await db.SaveChangesAsync();
return pocket;
}
}