Open funds

This commit is contained in:
2025-11-16 23:32:03 +08:00
parent 337cc1be97
commit 6313f15375
7 changed files with 2873 additions and 36 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class OpenableFunds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_open",
table: "wallet_funds",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "remaining_amount",
table: "wallet_funds",
type: "numeric",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_open",
table: "wallet_funds");
migrationBuilder.DropColumn(
name: "remaining_amount",
table: "wallet_funds");
}
}
}

View File

@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.10") .HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -1734,11 +1734,19 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Message") b.Property<string>("Message")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("message"); .HasColumnName("message");
b.Property<decimal>("RemainingAmount")
.HasColumnType("numeric")
.HasColumnName("remaining_amount");
b.Property<int>("SplitType") b.Property<int>("SplitType")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("split_type"); .HasColumnName("split_type");

View File

@@ -465,8 +465,7 @@ public class PaymentService(
null, null,
currency, currency,
fee, fee,
$"Transfer fee for transaction #{transaction.Id}", $"Transfer fee for transaction #{transaction.Id}");
Shared.Models.TransactionType.System);
return transaction; return transaction;
} }
@@ -480,52 +479,41 @@ public class PaymentService(
string? message = null, string? message = null,
Duration? expiration = null) Duration? expiration = null)
{ {
if (recipientAccountIds.Count == 0)
throw new ArgumentException("At least one recipient is required");
if (totalAmount <= 0) if (totalAmount <= 0)
throw new ArgumentException("Total amount must be positive"); throw new ArgumentException("Total amount must be positive");
// Validate all recipient accounts exist and have wallets
var recipientWallets = new List<SnWallet>();
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
recipientWallets.Add(wallet);
}
// Check creator has sufficient funds // Check creator has sufficient funds
var creatorWallet = await wat.GetWalletAsync(creatorAccountId); var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
if (creatorWallet == null) if (creatorWallet == null)
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}"); throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
// Validate all recipient accounts exist and have wallets
foreach (var accountId in recipientAccountIds)
{
var wallet = await wat.GetWalletAsync(accountId);
if (wallet == null)
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
}
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency); var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
if (creatorPocket.Amount < totalAmount) if (creatorPocket.Amount < totalAmount)
throw new InvalidOperationException("Insufficient funds"); throw new InvalidOperationException("Insufficient funds");
// Calculate amounts for each recipient
var recipientAmounts = splitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count),
Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count),
_ => throw new ArgumentException("Invalid split type")
};
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var fund = new SnWalletFund var fund = new SnWalletFund
{ {
CreatorAccountId = creatorAccountId, CreatorAccountId = creatorAccountId,
Currency = currency, Currency = currency,
TotalAmount = totalAmount, TotalAmount = totalAmount,
RemainingAmount = totalAmount,
SplitType = splitType, SplitType = splitType,
Message = message, Message = message,
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)), ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient IsOpen = recipientAccountIds.Count == 0,
Recipients = recipientAccountIds.Select(accountId => new SnWalletFundRecipient
{ {
RecipientAccountId = accountId, RecipientAccountId = accountId,
Amount = recipientAmounts[index] Amount = 0 // Amount will be calculated dynamically when claimed
}).ToList() }).ToList()
}; };
@@ -589,6 +577,36 @@ public class PaymentService(
return amounts; return amounts;
} }
private decimal CalculateDynamicAmount(SnWalletFund fund)
{
if (fund.RemainingAmount <= 0)
return 0;
// For open mode funds: use percentage-based calculation
if (fund.IsOpen)
{
const decimal percentagePerClaim = 0.1m; // 10% of remaining amount per claim
const decimal minimumAmount = 0.01m; // Minimum 0.01 per claim
var calculatedAmount = Math.Max(fund.RemainingAmount * percentagePerClaim, minimumAmount);
return Math.Min(calculatedAmount, fund.RemainingAmount);
}
// For closed mode funds: use split type calculation
else
{
var unclaimedRecipients = fund.Recipients.Count(r => !r.IsReceived);
if (unclaimedRecipients == 0)
return 0;
return fund.SplitType switch
{
Shared.Models.FundSplitType.Even => SplitEvenly(fund.RemainingAmount, unclaimedRecipients)[0],
Shared.Models.FundSplitType.Random => SplitRandomly(fund.RemainingAmount, unclaimedRecipients)[0],
_ => throw new ArgumentException("Invalid split type")
};
}
}
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId) public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
{ {
var fund = await db.WalletFunds var fund = await db.WalletFunds
@@ -598,12 +616,49 @@ public class PaymentService(
if (fund == null) if (fund == null)
throw new InvalidOperationException("Fund not found"); throw new InvalidOperationException("Fund not found");
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded) if (fund.Status is Shared.Models.FundStatus.Expired or Shared.Models.FundStatus.Refunded)
throw new InvalidOperationException("Fund is no longer available"); throw new InvalidOperationException("Fund is no longer available");
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId); var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (recipient == null)
// Handle open mode fund - create recipient if not exists
if (recipient is null && fund.IsOpen)
{
// Check if recipient has already claimed from this fund
var existingClaim = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
if (existingClaim != null)
throw new InvalidOperationException("You have already claimed from this fund");
// Calculate amount for new recipient
var amount = CalculateDynamicAmount(fund);
if (amount <= 0)
throw new InvalidOperationException("No funds remaining to claim");
// Create new recipient
recipient = new SnWalletFundRecipient
{
RecipientAccountId = recipientAccountId,
Amount = amount,
IsReceived = false
};
fund.Recipients.Add(recipient);
fund.RemainingAmount -= amount;
}
else if (recipient is null)
{
throw new InvalidOperationException("You are not a recipient of this fund"); throw new InvalidOperationException("You are not a recipient of this fund");
}
// For closed mode funds, calculate amount dynamically if not already set
if (!fund.IsOpen && recipient.Amount == 0)
{
var amount = CalculateDynamicAmount(fund);
if (amount <= 0)
throw new InvalidOperationException("No funds remaining to claim");
recipient.Amount = amount;
fund.RemainingAmount -= amount;
}
if (recipient.IsReceived) if (recipient.IsReceived)
throw new InvalidOperationException("You have already received this fund"); throw new InvalidOperationException("You have already received this fund");
@@ -628,11 +683,21 @@ public class PaymentService(
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant(); recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
// Update fund status // Update fund status
var allReceived = fund.Recipients.All(r => r.IsReceived); if (fund.IsOpen)
if (allReceived) {
fund.Status = Shared.Models.FundStatus.FullyReceived; if (fund.RemainingAmount <= 0)
fund.Status = Shared.Models.FundStatus.FullyReceived;
else
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
}
else else
fund.Status = Shared.Models.FundStatus.PartiallyReceived; {
var allReceived = fund.Recipients.All(r => r.IsReceived);
if (allReceived)
fund.Status = Shared.Models.FundStatus.FullyReceived;
else
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -334,11 +334,8 @@ public class WalletController(
} }
[HttpGet("funds/{id:guid}")] [HttpGet("funds/{id:guid}")]
[Authorize]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id) public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{ {
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var fund = await db.WalletFunds var fund = await db.WalletFunds
.Include(f => f.Recipients) .Include(f => f.Recipients)
.ThenInclude(r => r.RecipientAccount) .ThenInclude(r => r.RecipientAccount)

View File

@@ -85,9 +85,11 @@ public class SnWalletFund : ModelBase
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!; [MaxLength(128)] public string Currency { get; set; } = null!;
public decimal TotalAmount { get; set; } public decimal TotalAmount { get; set; }
public decimal RemainingAmount { get; set; }
public FundSplitType SplitType { get; set; } public FundSplitType SplitType { get; set; }
public FundStatus Status { get; set; } = FundStatus.Created; public FundStatus Status { get; set; } = FundStatus.Created;
[MaxLength(4096)] public string? Message { get; set; } [MaxLength(4096)] public string? Message { get; set; }
public bool IsOpen { get; set; } = false;
// Creator // Creator
public Guid CreatorAccountId { get; set; } public Guid CreatorAccountId { get; set; }
@@ -104,6 +106,7 @@ public class SnWalletFund : ModelBase
Id = Id.ToString(), Id = Id.ToString(),
Currency = Currency, Currency = Currency,
TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture), TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture),
RemainingAmount = RemainingAmount.ToString(CultureInfo.InvariantCulture),
SplitType = (Proto.FundSplitType)SplitType, SplitType = (Proto.FundSplitType)SplitType,
Status = (Proto.FundStatus)Status, Status = (Proto.FundStatus)Status,
Message = Message, Message = Message,
@@ -116,6 +119,7 @@ public class SnWalletFund : ModelBase
Id = Guid.Parse(proto.Id), Id = Guid.Parse(proto.Id),
Currency = proto.Currency, Currency = proto.Currency,
TotalAmount = decimal.Parse(proto.TotalAmount), TotalAmount = decimal.Parse(proto.TotalAmount),
RemainingAmount = proto.RemainingAmount is not null ? decimal.Parse(proto.RemainingAmount) : decimal.Parse(proto.TotalAmount),
SplitType = (FundSplitType)proto.SplitType, SplitType = (FundSplitType)proto.SplitType,
Status = (FundStatus)proto.Status, Status = (FundStatus)proto.Status,
Message = proto.Message, Message = proto.Message,

View File

@@ -41,6 +41,7 @@ message WalletFund {
string id = 1; string id = 1;
string currency = 2; string currency = 2;
string total_amount = 3; string total_amount = 3;
string remaining_amount = 10;
FundSplitType split_type = 4; FundSplitType split_type = 4;
FundStatus status = 5; FundStatus status = 5;
optional string message = 6; optional string message = 6;