✨ Open funds
This commit is contained in:
2722
DysonNetwork.Pass/Migrations/20251116153151_OpenableFunds.Designer.cs
generated
Normal file
2722
DysonNetwork.Pass/Migrations/20251116153151_OpenableFunds.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
DysonNetwork.Pass/Migrations/20251116153151_OpenableFunds.cs
Normal file
40
DysonNetwork.Pass/Migrations/20251116153151_OpenableFunds.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
if (fund.IsOpen)
|
||||||
|
{
|
||||||
|
if (fund.RemainingAmount <= 0)
|
||||||
|
fund.Status = Shared.Models.FundStatus.FullyReceived;
|
||||||
|
else
|
||||||
|
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var allReceived = fund.Recipients.All(r => r.IsReceived);
|
var allReceived = fund.Recipients.All(r => r.IsReceived);
|
||||||
if (allReceived)
|
if (allReceived)
|
||||||
fund.Status = Shared.Models.FundStatus.FullyReceived;
|
fund.Status = Shared.Models.FundStatus.FullyReceived;
|
||||||
else
|
else
|
||||||
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
|
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user