✨ Wallet funds
This commit is contained in:
613
API_WALLET_FUNDS.md
Normal file
613
API_WALLET_FUNDS.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Wallet Funds API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require Bearer token authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer {jwt_token}
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
### Enums
|
||||
|
||||
#### FundSplitType
|
||||
```typescript
|
||||
enum FundSplitType {
|
||||
Even = 0, // Equal distribution
|
||||
Random = 1 // Lucky draw distribution
|
||||
}
|
||||
```
|
||||
|
||||
#### FundStatus
|
||||
```typescript
|
||||
enum FundStatus {
|
||||
Created = 0, // Fund created, waiting for claims
|
||||
PartiallyReceived = 1, // Some recipients claimed
|
||||
FullyReceived = 2, // All recipients claimed
|
||||
Expired = 3, // Fund expired, unclaimed amounts refunded
|
||||
Refunded = 4 // Legacy status
|
||||
}
|
||||
```
|
||||
|
||||
### Request/Response Models
|
||||
|
||||
#### CreateFundRequest
|
||||
```typescript
|
||||
interface CreateFundRequest {
|
||||
recipientAccountIds: string[]; // UUIDs of recipients
|
||||
currency: string; // e.g., "points", "golds"
|
||||
totalAmount: number; // Total amount to distribute
|
||||
splitType: FundSplitType; // Even or Random
|
||||
message?: string; // Optional message
|
||||
expirationHours?: number; // Optional: hours until expiration (default: 24)
|
||||
pinCode: string; // Required: 6-digit PIN code for security
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFund
|
||||
```typescript
|
||||
interface SnWalletFund {
|
||||
id: string; // UUID
|
||||
currency: string;
|
||||
totalAmount: number;
|
||||
splitType: FundSplitType;
|
||||
status: FundStatus;
|
||||
message?: string;
|
||||
creatorAccountId: string; // UUID
|
||||
creatorAccount: SnAccount; // Creator account details (includes profile)
|
||||
recipients: SnWalletFundRecipient[];
|
||||
expiredAt: string; // ISO 8601 timestamp
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFundRecipient
|
||||
```typescript
|
||||
interface SnWalletFundRecipient {
|
||||
id: string; // UUID
|
||||
fundId: string; // UUID
|
||||
recipientAccountId: string; // UUID
|
||||
recipientAccount: SnAccount; // Recipient account details (includes profile)
|
||||
amount: number; // Allocated amount
|
||||
isReceived: boolean;
|
||||
receivedAt?: string; // ISO 8601 timestamp (if claimed)
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletTransaction
|
||||
```typescript
|
||||
interface SnWalletTransaction {
|
||||
id: string; // UUID
|
||||
payerWalletId?: string; // UUID (null for system transfers)
|
||||
payeeWalletId?: string; // UUID (null for system transfers)
|
||||
currency: string;
|
||||
amount: number;
|
||||
remarks?: string;
|
||||
type: TransactionType;
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
type: string; // Error type
|
||||
title: string; // Error title
|
||||
status: number; // HTTP status code
|
||||
detail: string; // Error details
|
||||
instance?: string; // Request instance
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Create Fund
|
||||
|
||||
Creates a new fund (red packet) for distribution among recipients.
|
||||
|
||||
**Endpoint:** `POST /api/wallets/funds`
|
||||
|
||||
**Request Body:** `CreateFundRequest`
|
||||
|
||||
**Response:** `SnWalletFund` (201 Created)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST "/api/wallets/funds" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipientAccountIds": [
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
"550e8400-e29b-41d4-a716-446655440002"
|
||||
],
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": "Even",
|
||||
"message": "Happy New Year! 🎉",
|
||||
"expirationHours": 48,
|
||||
"pinCode": "123456"
|
||||
}'
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": 0,
|
||||
"status": 0,
|
||||
"message": "Happy New Year! 🎉",
|
||||
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"creatorAccount": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"username": "creator_user"
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"amount": 33.34,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440006",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"amount": 33.33,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440007",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"amount": 33.33,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
],
|
||||
"expiredAt": "2025-10-05T22:00:00Z",
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `403 Forbidden`: Invalid PIN code
|
||||
- `422 Unprocessable Entity`: Business logic violations
|
||||
|
||||
---
|
||||
|
||||
### 2. Get Funds
|
||||
|
||||
Retrieves funds that the authenticated user is involved in (as creator or recipient).
|
||||
|
||||
**Endpoint:** `GET /api/wallets/funds`
|
||||
|
||||
**Query Parameters:**
|
||||
- `offset` (number, optional): Pagination offset (default: 0)
|
||||
- `take` (number, optional): Number of items to return (default: 20, max: 100)
|
||||
- `status` (FundStatus, optional): Filter by fund status
|
||||
|
||||
**Response:** `SnWalletFund[]` (200 OK)
|
||||
|
||||
**Headers:**
|
||||
- `X-Total`: Total number of funds matching the criteria
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": 0,
|
||||
"status": 0,
|
||||
"message": "Happy New Year! 🎉",
|
||||
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"creatorAccount": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"username": "creator_user"
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"amount": 33.34,
|
||||
"isReceived": false
|
||||
}
|
||||
],
|
||||
"expiredAt": "2025-10-05T22:00:00Z",
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Fund
|
||||
|
||||
Retrieves details of a specific fund.
|
||||
|
||||
**Endpoint:** `GET /api/wallets/funds/{id}`
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Fund UUID
|
||||
|
||||
**Response:** `SnWalletFund` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:** (Same as create fund response)
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `403 Forbidden`: User doesn't have permission to view this fund
|
||||
- `404 Not Found`: Fund not found
|
||||
|
||||
---
|
||||
|
||||
### 4. Receive Fund
|
||||
|
||||
Claims the authenticated user's portion of a fund.
|
||||
|
||||
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Fund UUID
|
||||
|
||||
**Response:** `SnWalletTransaction` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"payerWalletId": null,
|
||||
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
|
||||
"currency": "points",
|
||||
"amount": 33.34,
|
||||
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
|
||||
"type": 1,
|
||||
"createdAt": "2025-10-03T22:05:00Z",
|
||||
"updatedAt": "2025-10-03T22:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Fund expired, already claimed, not a recipient
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `404 Not Found`: Fund not found
|
||||
|
||||
---
|
||||
|
||||
### 5. Get Wallet Overview
|
||||
|
||||
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
|
||||
|
||||
**Endpoint:** `GET /api/wallets/overview`
|
||||
|
||||
**Query Parameters:**
|
||||
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
|
||||
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
|
||||
|
||||
**Response:** `WalletOverview` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"accountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"startDate": "2025-01-01T00:00:00.0000000Z",
|
||||
"endDate": "2025-12-31T23:59:59.0000000Z",
|
||||
"summary": {
|
||||
"System": {
|
||||
"type": "System",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 150.00,
|
||||
"spending": 0.00,
|
||||
"net": 150.00
|
||||
}
|
||||
}
|
||||
},
|
||||
"Transfer": {
|
||||
"type": "Transfer",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 25.00,
|
||||
"spending": 75.00,
|
||||
"net": -50.00
|
||||
},
|
||||
"golds": {
|
||||
"currency": "golds",
|
||||
"income": 0.00,
|
||||
"spending": 10.00,
|
||||
"net": -10.00
|
||||
}
|
||||
}
|
||||
},
|
||||
"Order": {
|
||||
"type": "Order",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 0.00,
|
||||
"spending": 200.00,
|
||||
"net": -200.00
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"totalIncome": 175.00,
|
||||
"totalSpending": 285.00,
|
||||
"netTotal": -110.00
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
- `accountId`: User's account UUID
|
||||
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
|
||||
- `summary`: Object keyed by transaction type
|
||||
- `type`: Transaction type name
|
||||
- `currencies`: Object keyed by currency code
|
||||
- `currency`: Currency name
|
||||
- `income`: Total money received
|
||||
- `spending`: Total money spent
|
||||
- `net`: Income minus spending
|
||||
- `totalIncome`: Sum of all income across all types/currencies
|
||||
- `totalSpending`: Sum of all spending across all types/currencies
|
||||
- `netTotal`: Overall net (totalIncome - totalSpending)
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
|
||||
## Error Codes
|
||||
|
||||
### Common Error Types
|
||||
|
||||
#### Validation Errors
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "At least one recipient is required",
|
||||
"instance": "/api/wallets/funds"
|
||||
}
|
||||
```
|
||||
|
||||
#### Insufficient Funds
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Insufficient funds",
|
||||
"instance": "/api/wallets/funds"
|
||||
}
|
||||
```
|
||||
|
||||
#### Fund Not Available
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Fund is no longer available",
|
||||
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||
}
|
||||
```
|
||||
|
||||
#### Already Claimed
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "You have already received this fund",
|
||||
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Create Fund**: 10 requests per minute per user
|
||||
- **Get Funds**: 60 requests per minute per user
|
||||
- **Get Fund**: 60 requests per minute per user
|
||||
- **Receive Fund**: 30 requests per minute per user
|
||||
|
||||
## Webhooks/Notifications
|
||||
|
||||
The system integrates with the platform's notification system:
|
||||
|
||||
- **Fund Created**: Creator receives confirmation
|
||||
- **Fund Claimed**: Creator receives notification when someone claims
|
||||
- **Fund Expired**: Creator receives refund notification
|
||||
|
||||
## SDK Examples
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// Create a fund
|
||||
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
|
||||
const response = await fetch('/api/wallets/funds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(fundData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Get user's funds
|
||||
const getFunds = async (params?: {
|
||||
offset?: number;
|
||||
take?: number;
|
||||
status?: FundStatus;
|
||||
}): Promise<SnWalletFund[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.offset) queryParams.set('offset', params.offset.toString());
|
||||
if (params?.take) queryParams.set('take', params.take.toString());
|
||||
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
|
||||
|
||||
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Claim a fund
|
||||
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
|
||||
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
class FundSplitType(Enum):
|
||||
EVEN = 0
|
||||
RANDOM = 1
|
||||
|
||||
class FundStatus(Enum):
|
||||
CREATED = 0
|
||||
PARTIALLY_RECEIVED = 1
|
||||
FULLY_RECEIVED = 2
|
||||
EXPIRED = 3
|
||||
REFUNDED = 4
|
||||
|
||||
def create_fund(token: str, fund_data: dict) -> dict:
|
||||
"""Create a new fund"""
|
||||
response = requests.post(
|
||||
'/api/wallets/funds',
|
||||
json=fund_data,
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_funds(
|
||||
token: str,
|
||||
offset: int = 0,
|
||||
take: int = 20,
|
||||
status: Optional[FundStatus] = None
|
||||
) -> List[dict]:
|
||||
"""Get user's funds"""
|
||||
params = {'offset': offset, 'take': take}
|
||||
if status is not None:
|
||||
params['status'] = status.value
|
||||
|
||||
response = requests.get(
|
||||
'/api/wallets/funds',
|
||||
params=params,
|
||||
headers={'Authorization': f'Bearer {token}'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def receive_fund(token: str, fund_id: str) -> dict:
|
||||
"""Claim a fund portion"""
|
||||
response = requests.post(
|
||||
f'/api/wallets/funds/{fund_id}/receive',
|
||||
headers={'Authorization': f'Bearer {token}'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0
|
||||
- Initial release with basic red packet functionality
|
||||
- Support for even and random split types
|
||||
- 24-hour expiration with automatic refunds
|
||||
- RESTful API endpoints
|
||||
- Comprehensive error handling
|
||||
|
||||
## Support
|
||||
|
||||
For API support or questions:
|
||||
- Check the main documentation at `README_WALLET_FUNDS.md`
|
||||
- Review error messages for specific guidance
|
||||
- Contact the development team for technical issues
|
@@ -43,6 +43,8 @@ public class AppDatabase(
|
||||
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
|
||||
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
|
||||
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
|
||||
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
|
||||
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
|
||||
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
|
||||
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
|
||||
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
|
||||
|
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefactorSubscriptionRelation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "subscription_id",
|
||||
table: "wallet_gifts",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_subscription_id",
|
||||
table: "wallet_gifts",
|
||||
column: "subscription_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
|
||||
table: "wallet_gifts",
|
||||
column: "subscription_id",
|
||||
principalTable: "wallet_subscriptions",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wallet_gifts_subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
principalTable: "wallet_gifts",
|
||||
principalColumn: "id");
|
||||
}
|
||||
}
|
||||
}
|
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal file
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWalletFund : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_funds",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
total_amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
split_type = table.Column<int>(type: "integer", nullable: false),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
creator_account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_funds", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_funds_accounts_creator_account_id",
|
||||
column: x => x.creator_account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_fund_recipients",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
fund_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
recipient_account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
is_received = table.Column<bool>(type: "boolean", nullable: false),
|
||||
received_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_fund_recipients", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_fund_recipients_accounts_recipient_account_id",
|
||||
column: x => x.recipient_account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_fund_recipients_wallet_funds_fund_id",
|
||||
column: x => x.fund_id,
|
||||
principalTable: "wallet_funds",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_fund_recipients_fund_id",
|
||||
table: "wallet_fund_recipients",
|
||||
column: "fund_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_fund_recipients_recipient_account_id",
|
||||
table: "wallet_fund_recipients",
|
||||
column: "recipient_account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_funds_creator_account_id",
|
||||
table: "wallet_funds",
|
||||
column: "creator_account_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_fund_recipients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_funds");
|
||||
}
|
||||
}
|
||||
}
|
@@ -1387,6 +1387,116 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("wallet_coupons", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("CreatorAccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("creator_account_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<int>("SplitType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("split_type");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<decimal>("TotalAmount")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("total_amount");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_funds");
|
||||
|
||||
b.HasIndex("CreatorAccountId")
|
||||
.HasDatabaseName("ix_wallet_funds_creator_account_id");
|
||||
|
||||
b.ToTable("wallet_funds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid>("FundId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("fund_id");
|
||||
|
||||
b.Property<bool>("IsReceived")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_received");
|
||||
|
||||
b.Property<Instant?>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<Guid>("RecipientAccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("recipient_account_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_fund_recipients");
|
||||
|
||||
b.HasIndex("FundId")
|
||||
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
|
||||
|
||||
b.HasIndex("RecipientAccountId")
|
||||
.HasDatabaseName("ix_wallet_fund_recipients_recipient_account_id");
|
||||
|
||||
b.ToTable("wallet_fund_recipients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1464,6 +1574,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid?>("SubscriptionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("subscription_id");
|
||||
|
||||
b.Property<string>("SubscriptionIdentifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
@@ -1492,6 +1606,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("RedeemerId")
|
||||
.HasDatabaseName("ix_wallet_gifts_redeemer_id");
|
||||
|
||||
b.HasIndex("SubscriptionId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallet_gifts_subscription_id");
|
||||
|
||||
b.ToTable("wallet_gifts", (string)null);
|
||||
});
|
||||
|
||||
@@ -1648,10 +1766,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<Guid?>("GiftId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("gift_id");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
@@ -1698,10 +1812,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("CouponId")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
|
||||
|
||||
b.HasIndex("GiftId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallet_subscriptions_gift_id");
|
||||
|
||||
b.HasIndex("Identifier")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_identifier");
|
||||
|
||||
@@ -2055,6 +2165,39 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "CreatorAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_funds_accounts_creator_account_id");
|
||||
|
||||
b.Navigation("CreatorAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
|
||||
.WithMany("Recipients")
|
||||
.HasForeignKey("FundId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "RecipientAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("RecipientAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_fund_recipients_accounts_recipient_account_id");
|
||||
|
||||
b.Navigation("Fund");
|
||||
|
||||
b.Navigation("RecipientAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
|
||||
@@ -2079,6 +2222,11 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasForeignKey("RedeemerId")
|
||||
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
|
||||
.WithOne("Gift")
|
||||
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
|
||||
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
|
||||
|
||||
b.Navigation("Coupon");
|
||||
|
||||
b.Navigation("Gifter");
|
||||
@@ -2086,6 +2234,8 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Recipient");
|
||||
|
||||
b.Navigation("Redeemer");
|
||||
|
||||
b.Navigation("Subscription");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
|
||||
@@ -2131,16 +2281,9 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasForeignKey("CouponId")
|
||||
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletGift", "Gift")
|
||||
.WithOne("Subscription")
|
||||
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletSubscription", "GiftId")
|
||||
.HasConstraintName("fk_wallet_subscriptions_wallet_gifts_gift_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Coupon");
|
||||
|
||||
b.Navigation("Gift");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
|
||||
@@ -2194,9 +2337,14 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Pockets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.Navigation("Subscription");
|
||||
b.Navigation("Recipients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
|
||||
{
|
||||
b.Navigation("Gift");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
@@ -56,6 +56,16 @@ public static class ScheduledJobsConfiguration
|
||||
.WithIntervalInHours(1)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var fundExpirationJob = new JobKey("FundExpiration");
|
||||
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity(fundExpirationJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(fundExpirationJob)
|
||||
.WithIdentity("FundExpirationTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInHours(1)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal file
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class FundExpirationJob(
|
||||
AppDatabase db,
|
||||
PaymentService paymentService,
|
||||
ILogger<FundExpirationJob> logger
|
||||
) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Starting fund expiration job...");
|
||||
|
||||
try
|
||||
{
|
||||
await paymentService.ProcessExpiredFundsAsync();
|
||||
logger.LogInformation("Successfully processed expired funds");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing expired funds");
|
||||
}
|
||||
}
|
||||
}
|
@@ -447,4 +447,315 @@ public class PaymentService(
|
||||
$"Transfer from account {payerAccountId} to {payeeAccountId}",
|
||||
Shared.Models.TransactionType.Transfer);
|
||||
}
|
||||
|
||||
public async Task<SnWalletFund> CreateFundAsync(
|
||||
Guid creatorAccountId,
|
||||
List<Guid> recipientAccountIds,
|
||||
string currency,
|
||||
decimal totalAmount,
|
||||
Shared.Models.FundSplitType splitType,
|
||||
string? message = null,
|
||||
Duration? expiration = null)
|
||||
{
|
||||
if (recipientAccountIds.Count == 0)
|
||||
throw new ArgumentException("At least one recipient is required");
|
||||
|
||||
if (totalAmount <= 0)
|
||||
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
|
||||
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
|
||||
if (creatorWallet == null)
|
||||
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
|
||||
|
||||
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
|
||||
if (creatorPocket.Amount < totalAmount)
|
||||
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 fund = new SnWalletFund
|
||||
{
|
||||
CreatorAccountId = creatorAccountId,
|
||||
Currency = currency,
|
||||
TotalAmount = totalAmount,
|
||||
SplitType = splitType,
|
||||
Message = message,
|
||||
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
|
||||
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient
|
||||
{
|
||||
RecipientAccountId = accountId,
|
||||
Amount = recipientAmounts[index]
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Deduct from creator's wallet
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == creatorPocket.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
|
||||
|
||||
db.WalletFunds.Add(fund);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Load the fund with account data including profiles
|
||||
var createdFund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.FirstOrDefaultAsync(f => f.Id == fund.Id);
|
||||
|
||||
return createdFund!;
|
||||
}
|
||||
|
||||
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
|
||||
var remainder = totalAmount - (baseAmount * recipientCount);
|
||||
|
||||
var amounts = new List<decimal>();
|
||||
for (int i = 0; i < recipientCount; i++)
|
||||
{
|
||||
var amount = baseAmount;
|
||||
if (i < remainder * 100) // Distribute remainder as 0.01 increments
|
||||
amount += 0.01m;
|
||||
amounts.Add(amount);
|
||||
}
|
||||
|
||||
return amounts;
|
||||
}
|
||||
|
||||
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var random = new Random();
|
||||
var amounts = new List<decimal>();
|
||||
|
||||
// Generate random amounts that sum to total
|
||||
decimal remaining = totalAmount;
|
||||
for (int i = 0; i < recipientCount - 1; i++)
|
||||
{
|
||||
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
|
||||
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
|
||||
var minAmount = 0.01m;
|
||||
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
|
||||
amounts.Add(amount);
|
||||
remaining -= amount;
|
||||
}
|
||||
|
||||
// Last recipient gets the remainder
|
||||
amounts.Add(Math.Round(remaining, 2));
|
||||
|
||||
return amounts;
|
||||
}
|
||||
|
||||
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
|
||||
{
|
||||
var fund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.FirstOrDefaultAsync(f => f.Id == fundId);
|
||||
|
||||
if (fund == null)
|
||||
throw new InvalidOperationException("Fund not found");
|
||||
|
||||
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded)
|
||||
throw new InvalidOperationException("Fund is no longer available");
|
||||
|
||||
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
|
||||
if (recipient == null)
|
||||
throw new InvalidOperationException("You are not a recipient of this fund");
|
||||
|
||||
if (recipient.IsReceived)
|
||||
throw new InvalidOperationException("You have already received this fund");
|
||||
|
||||
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
|
||||
if (recipientWallet == null)
|
||||
throw new InvalidOperationException("Recipient wallet not found");
|
||||
|
||||
// Create transaction to transfer funds to recipient
|
||||
var transaction = await CreateTransactionAsync(
|
||||
payerWalletId: null, // System transfer
|
||||
payeeWalletId: recipientWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: recipient.Amount,
|
||||
remarks: $"Received fund portion from {fund.CreatorAccountId}",
|
||||
type: Shared.Models.TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
|
||||
// Mark as received
|
||||
recipient.IsReceived = true;
|
||||
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Update fund status
|
||||
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();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task ProcessExpiredFundsAsync()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var expiredFunds = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived)
|
||||
.Where(f => f.ExpiredAt < now)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var fund in expiredFunds)
|
||||
{
|
||||
// Calculate unclaimed amount
|
||||
var unclaimedAmount = fund.Recipients
|
||||
.Where(r => !r.IsReceived)
|
||||
.Sum(r => r.Amount);
|
||||
|
||||
if (unclaimedAmount > 0)
|
||||
{
|
||||
// Refund to creator
|
||||
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
|
||||
if (creatorWallet != null)
|
||||
{
|
||||
await CreateTransactionAsync(
|
||||
payerWalletId: null, // System refund
|
||||
payeeWalletId: creatorWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: unclaimedAmount,
|
||||
remarks: $"Refund for expired fund {fund.Id}",
|
||||
type: Shared.Models.TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fund.Status = Shared.Models.FundStatus.Expired;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
|
||||
{
|
||||
var wallet = await wat.GetWalletAsync(accountId);
|
||||
if (wallet == null)
|
||||
throw new InvalidOperationException("Wallet not found");
|
||||
|
||||
var query = db.PaymentTransactions
|
||||
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
|
||||
|
||||
if (startDate.HasValue)
|
||||
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
|
||||
if (endDate.HasValue)
|
||||
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
|
||||
|
||||
var transactions = await query.ToListAsync();
|
||||
|
||||
var overview = new WalletOverview
|
||||
{
|
||||
AccountId = accountId,
|
||||
StartDate = startDate?.ToString("O"),
|
||||
EndDate = endDate?.ToString("O"),
|
||||
Summary = new Dictionary<string, TransactionSummary>()
|
||||
};
|
||||
|
||||
// Group transactions by type and currency
|
||||
var groupedTransactions = transactions
|
||||
.GroupBy(t => new { t.Type, t.Currency })
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var group in groupedTransactions)
|
||||
{
|
||||
var typeName = group.Key.Type.ToString();
|
||||
var currency = group.Key.Currency;
|
||||
|
||||
if (!overview.Summary.ContainsKey(typeName))
|
||||
{
|
||||
overview.Summary[typeName] = new TransactionSummary
|
||||
{
|
||||
Type = typeName,
|
||||
Currencies = new Dictionary<string, CurrencySummary>()
|
||||
};
|
||||
}
|
||||
|
||||
var currencySummary = new CurrencySummary
|
||||
{
|
||||
Currency = currency,
|
||||
Income = 0,
|
||||
Spending = 0,
|
||||
Net = 0
|
||||
};
|
||||
|
||||
foreach (var transaction in group.Value)
|
||||
{
|
||||
if (transaction.PayeeWalletId == wallet.Id)
|
||||
{
|
||||
// Money coming in
|
||||
currencySummary.Income += transaction.Amount;
|
||||
}
|
||||
else if (transaction.PayerWalletId == wallet.Id)
|
||||
{
|
||||
// Money going out
|
||||
currencySummary.Spending += transaction.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
|
||||
overview.Summary[typeName].Currencies[currency] = currencySummary;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
|
||||
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
|
||||
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
|
||||
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
|
||||
public class WalletOverview
|
||||
{
|
||||
public Guid AccountId { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
|
||||
public decimal TotalIncome { get; set; }
|
||||
public decimal TotalSpending { get; set; }
|
||||
public decimal NetTotal { get; set; }
|
||||
}
|
||||
|
||||
public class TransactionSummary
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CurrencySummary
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public decimal Income { get; set; }
|
||||
public decimal Spending { get; set; }
|
||||
public decimal Net { get; set; }
|
||||
}
|
@@ -696,6 +696,56 @@ public class SubscriptionService(
|
||||
if (subscriptionInfo is null)
|
||||
throw new InvalidOperationException("Invalid gift subscription type.");
|
||||
|
||||
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
|
||||
if (sameTypeSubscription is not null)
|
||||
{
|
||||
// Extend existing subscription
|
||||
var subscriptionDuration = Duration.FromDays(28);
|
||||
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
|
||||
{
|
||||
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
|
||||
}
|
||||
else
|
||||
{
|
||||
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
|
||||
}
|
||||
|
||||
if (sameTypeSubscription.RenewalAt.HasValue)
|
||||
{
|
||||
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
|
||||
}
|
||||
|
||||
// Update gift status and link
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
|
||||
gift.RedeemedAt = now;
|
||||
gift.RedeemerId = redeemer.Id;
|
||||
gift.SubscriptionId = sameTypeSubscription.Id;
|
||||
gift.UpdatedAt = now;
|
||||
|
||||
using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
db.WalletSubscriptions.Update(sameTypeSubscription);
|
||||
db.WalletGifts.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
await NotifyGiftRedeemed(gift, sameTypeSubscription, redeemer);
|
||||
if (gift.GifterId != redeemer.Id)
|
||||
{
|
||||
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
|
||||
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
|
||||
}
|
||||
|
||||
return (gift, sameTypeSubscription);
|
||||
}
|
||||
|
||||
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||
? SubscriptionTypeData.SubscriptionDict
|
||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||
@@ -710,7 +760,7 @@ public class SubscriptionService(
|
||||
// We do not check account level requirement, since it is a gift
|
||||
|
||||
// Create the subscription from the gift
|
||||
var cycleDuration = Duration.FromDays(28); // Standard 28-day subscription
|
||||
var cycleDuration = Duration.FromDays(28);
|
||||
var subscription = new SnWalletSubscription
|
||||
{
|
||||
BegunAt = now,
|
||||
@@ -730,7 +780,6 @@ public class SubscriptionService(
|
||||
Coupon = gift.Coupon,
|
||||
RenewalAt = now.Plus(cycleDuration),
|
||||
AccountId = redeemer.Id,
|
||||
GiftId = gift.Id
|
||||
};
|
||||
|
||||
// Update the gift status
|
||||
@@ -741,18 +790,18 @@ public class SubscriptionService(
|
||||
gift.UpdatedAt = now;
|
||||
|
||||
// Save both gift and subscription
|
||||
using var transaction = await db.Database.BeginTransactionAsync();
|
||||
using var createTransaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
db.WalletSubscriptions.Add(subscription);
|
||||
db.WalletGifts.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
await createTransaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
await createTransaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/wallets")]
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
@@ -102,6 +104,14 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
[Required] public Guid AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class WalletTransferRequest
|
||||
{
|
||||
public string? Remark { get; set; }
|
||||
[Required] public decimal Amount { get; set; }
|
||||
[Required] public string Currency { get; set; } = null!;
|
||||
[Required] public Guid PayeeAccountId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("balance")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
||||
@@ -128,4 +138,194 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
|
||||
[HttpPost("transfer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var payerWallet = await ws.GetWalletAsync(currentUser.Id);
|
||||
if (payerWallet is null) return NotFound("Your wallet was not found, please create one first.");
|
||||
|
||||
var payeeWallet = await ws.GetWalletAsync(request.PayeeAccountId);
|
||||
if (payeeWallet is null) return NotFound("Payee wallet was not found.");
|
||||
|
||||
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
|
||||
|
||||
try
|
||||
{
|
||||
var transaction = await payment.CreateTransactionAsync(
|
||||
payerWalletId: payerWallet.Id,
|
||||
payeeWalletId: payeeWallet.Id,
|
||||
currency: request.Currency,
|
||||
amount: request.Amount,
|
||||
remarks: request.Remark ?? $"Transfer from {currentUser.Id} to {request.PayeeAccountId}",
|
||||
type: TransactionType.Transfer
|
||||
);
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateFundRequest
|
||||
{
|
||||
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
|
||||
[Required] public string Currency { get; set; } = null!;
|
||||
[Required] public decimal TotalAmount { get; set; }
|
||||
[Required] public FundSplitType SplitType { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public int? ExpirationHours { get; set; } // Optional: hours until expiration
|
||||
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
|
||||
}
|
||||
|
||||
[HttpPost("funds")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
// Validate PIN code
|
||||
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
|
||||
return StatusCode(403, "Invalid PIN Code");
|
||||
|
||||
try
|
||||
{
|
||||
Duration? expiration = null;
|
||||
if (request.ExpirationHours.HasValue)
|
||||
{
|
||||
expiration = Duration.FromHours(request.ExpirationHours.Value);
|
||||
}
|
||||
|
||||
var fund = await payment.CreateFundAsync(
|
||||
creatorAccountId: currentUser.Id,
|
||||
recipientAccountIds: request.RecipientAccountIds,
|
||||
currency: request.Currency,
|
||||
totalAmount: request.TotalAmount,
|
||||
splitType: request.SplitType,
|
||||
message: request.Message,
|
||||
expiration: expiration
|
||||
);
|
||||
|
||||
return Ok(fund);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("funds")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] FundStatus? status = null
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var query = db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Where(f => f.CreatorAccountId == currentUser.Id ||
|
||||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(f => f.Status == status.Value);
|
||||
}
|
||||
|
||||
var fundCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = fundCount.ToString();
|
||||
|
||||
var funds = await query
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(funds);
|
||||
}
|
||||
|
||||
[HttpGet("funds/{id}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var fund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.FirstOrDefaultAsync(f => f.Id == id);
|
||||
|
||||
if (fund == null)
|
||||
return NotFound("Fund not found");
|
||||
|
||||
// Check if user is creator or recipient
|
||||
var isCreator = fund.CreatorAccountId == currentUser.Id;
|
||||
var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id);
|
||||
|
||||
if (!isCreator && !isRecipient)
|
||||
return Forbid("You don't have permission to view this fund");
|
||||
|
||||
return Ok(fund);
|
||||
}
|
||||
|
||||
[HttpPost("funds/{id}/receive")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var transaction = await payment.ReceiveFundAsync(
|
||||
recipientAccountId: currentUser.Id,
|
||||
fundId: id
|
||||
);
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("overview")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var overview = await payment.GetWalletOverviewAsync(
|
||||
accountId: currentUser.Id,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
);
|
||||
|
||||
return Ok(overview);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,10 +2,8 @@ using System.Text.Json;
|
||||
using DysonNetwork.Ring.Email;
|
||||
using DysonNetwork.Ring.Notification;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Google.Protobuf;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Net;
|
||||
|
||||
namespace DysonNetwork.Ring.Services;
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
@@ -128,7 +129,8 @@ public class SnWalletGift : ModelBase
|
||||
/// <summary>
|
||||
/// The subscription created when the gift is redeemed.
|
||||
/// </summary>
|
||||
public SnWalletSubscription? Subscription { get; set; }
|
||||
[JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
|
||||
public Guid? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the gift expires and can no longer be redeemed.
|
||||
@@ -337,7 +339,6 @@ public class SnWalletSubscription : ModelBase
|
||||
/// <summary>
|
||||
/// If this subscription was redeemed from a gift, this references the gift record.
|
||||
/// </summary>
|
||||
public Guid? GiftId { get; set; }
|
||||
public SnWalletGift? Gift { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
|
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
@@ -62,3 +64,96 @@ public class SnWalletPocket : ModelBase
|
||||
WalletId = Guid.Parse(proto.WalletId),
|
||||
};
|
||||
}
|
||||
|
||||
public enum FundSplitType
|
||||
{
|
||||
Even,
|
||||
Random
|
||||
}
|
||||
|
||||
public enum FundStatus
|
||||
{
|
||||
Created,
|
||||
PartiallyReceived,
|
||||
FullyReceived,
|
||||
Expired,
|
||||
Refunded
|
||||
}
|
||||
|
||||
public class SnWalletFund : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
public decimal TotalAmount { get; set; }
|
||||
public FundSplitType SplitType { get; set; }
|
||||
public FundStatus Status { get; set; } = FundStatus.Created;
|
||||
[MaxLength(4096)] public string? Message { get; set; }
|
||||
|
||||
// Creator
|
||||
public Guid CreatorAccountId { get; set; }
|
||||
public SnAccount CreatorAccount { get; set; } = null!;
|
||||
|
||||
// Recipients
|
||||
public ICollection<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>();
|
||||
|
||||
// Expiration
|
||||
public Instant ExpiredAt { get; set; }
|
||||
|
||||
public Proto.WalletFund ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Currency = Currency,
|
||||
TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture),
|
||||
SplitType = (Proto.FundSplitType)SplitType,
|
||||
Status = (Proto.FundStatus)Status,
|
||||
Message = Message,
|
||||
CreatorAccountId = CreatorAccountId.ToString(),
|
||||
ExpiredAt = ExpiredAt.ToTimestamp(),
|
||||
};
|
||||
|
||||
public static SnWalletFund FromProtoValue(Proto.WalletFund proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
Currency = proto.Currency,
|
||||
TotalAmount = decimal.Parse(proto.TotalAmount),
|
||||
SplitType = (FundSplitType)proto.SplitType,
|
||||
Status = (FundStatus)proto.Status,
|
||||
Message = proto.Message,
|
||||
CreatorAccountId = Guid.Parse(proto.CreatorAccountId),
|
||||
ExpiredAt = proto.ExpiredAt.ToInstant(),
|
||||
};
|
||||
}
|
||||
|
||||
public class SnWalletFundRecipient : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid FundId { get; set; }
|
||||
[JsonIgnore] public SnWalletFund Fund { get; set; } = null!;
|
||||
|
||||
public Guid RecipientAccountId { get; set; }
|
||||
public SnAccount RecipientAccount { get; set; } = null!;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsReceived { get; set; } = false;
|
||||
public Instant? ReceivedAt { get; set; }
|
||||
|
||||
public Proto.WalletFundRecipient ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
FundId = FundId.ToString(),
|
||||
RecipientAccountId = RecipientAccountId.ToString(),
|
||||
Amount = Amount.ToString(CultureInfo.InvariantCulture),
|
||||
IsReceived = IsReceived,
|
||||
ReceivedAt = ReceivedAt?.ToTimestamp(),
|
||||
};
|
||||
|
||||
public static SnWalletFundRecipient FromProtoValue(Proto.WalletFundRecipient proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
FundId = Guid.Parse(proto.FundId),
|
||||
RecipientAccountId = Guid.Parse(proto.RecipientAccountId),
|
||||
Amount = decimal.Parse(proto.Amount),
|
||||
IsReceived = proto.IsReceived,
|
||||
ReceivedAt = proto.ReceivedAt?.ToInstant(),
|
||||
};
|
||||
}
|
||||
|
@@ -22,6 +22,42 @@ message WalletPocket {
|
||||
string wallet_id = 4;
|
||||
}
|
||||
|
||||
enum FundSplitType {
|
||||
FUND_SPLIT_TYPE_UNSPECIFIED = 0;
|
||||
FUND_SPLIT_TYPE_EVEN = 1;
|
||||
FUND_SPLIT_TYPE_RANDOM = 2;
|
||||
}
|
||||
|
||||
enum FundStatus {
|
||||
FUND_STATUS_UNSPECIFIED = 0;
|
||||
FUND_STATUS_CREATED = 1;
|
||||
FUND_STATUS_PARTIALLY_RECEIVED = 2;
|
||||
FUND_STATUS_FULLY_RECEIVED = 3;
|
||||
FUND_STATUS_EXPIRED = 4;
|
||||
FUND_STATUS_REFUNDED = 5;
|
||||
}
|
||||
|
||||
message WalletFund {
|
||||
string id = 1;
|
||||
string currency = 2;
|
||||
string total_amount = 3;
|
||||
FundSplitType split_type = 4;
|
||||
FundStatus status = 5;
|
||||
optional string message = 6;
|
||||
string creator_account_id = 7;
|
||||
google.protobuf.Timestamp expired_at = 8;
|
||||
repeated WalletFundRecipient recipients = 9;
|
||||
}
|
||||
|
||||
message WalletFundRecipient {
|
||||
string id = 1;
|
||||
string fund_id = 2;
|
||||
string recipient_account_id = 3;
|
||||
string amount = 4;
|
||||
bool is_received = 5;
|
||||
optional google.protobuf.Timestamp received_at = 6;
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
// Using proto3 enum naming convention
|
||||
SUBSCRIPTION_STATUS_UNSPECIFIED = 0;
|
||||
|
429
README_WALLET_FUNDS.md
Normal file
429
README_WALLET_FUNDS.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Wallet Funds (Red Packet) System
|
||||
|
||||
## Overview
|
||||
|
||||
The Wallet Funds system implements red packet functionality for the DysonNetwork platform, allowing users to create funds that can be split among multiple recipients. Recipients must explicitly claim their portion, and unclaimed funds are automatically refunded after expiration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Red Packet Creation**: Users can create funds with total amounts to be distributed
|
||||
- **Split Types**: Even distribution or random (lucky draw) splitting
|
||||
- **Claim System**: Recipients must actively claim their portion
|
||||
- **Expiration**: Automatic refund of unclaimed funds after 24 hours
|
||||
- **Multi-Recipient**: Support for distributing to multiple users simultaneously
|
||||
- **Audit Trail**: Full transaction history and status tracking
|
||||
|
||||
## Architecture
|
||||
|
||||
### Models
|
||||
|
||||
#### SnWalletFund
|
||||
```csharp
|
||||
public class SnWalletFund : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public FundSplitType SplitType { get; set; }
|
||||
public FundStatus Status { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public Guid CreatorAccountId { get; set; }
|
||||
public SnAccount CreatorAccount { get; set; }
|
||||
public ICollection<SnWalletFundRecipient> Recipients { get; set; }
|
||||
public Instant ExpiredAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFundRecipient
|
||||
```csharp
|
||||
public class SnWalletFundRecipient : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FundId { get; set; }
|
||||
public SnWalletFund Fund { get; set; }
|
||||
public Guid RecipientAccountId { get; set; }
|
||||
public SnAccount RecipientAccount { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsReceived { get; set; }
|
||||
public Instant? ReceivedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Enums
|
||||
|
||||
#### FundSplitType
|
||||
- `Even`: Equal distribution among all recipients
|
||||
- `Random`: Random amounts that sum to total
|
||||
|
||||
#### FundStatus
|
||||
- `Created`: Fund created, waiting for claims
|
||||
- `PartiallyReceived`: Some recipients have claimed
|
||||
- `FullyReceived`: All recipients have claimed
|
||||
- `Expired`: Fund expired, unclaimed amounts refunded
|
||||
- `Refunded`: Fund was refunded (legacy status)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Create Fund
|
||||
**POST** `/api/wallets/funds`
|
||||
|
||||
Creates a new fund (red packet) for distribution among recipients.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"recipientAccountIds": ["uuid1", "uuid2", "uuid3"],
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": "Even",
|
||||
"message": "Happy Birthday! 🎉",
|
||||
"expirationHours": 48
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `SnWalletFund` object
|
||||
|
||||
**Authorization:** Required (authenticated user becomes the creator)
|
||||
|
||||
---
|
||||
|
||||
### Get Funds
|
||||
**GET** `/api/wallets/funds`
|
||||
|
||||
Retrieves funds that the authenticated user is involved in (as creator or recipient).
|
||||
|
||||
**Query Parameters:**
|
||||
- `offset` (int, optional): Pagination offset (default: 0)
|
||||
- `take` (int, optional): Number of items to return (default: 20)
|
||||
- `status` (FundStatus, optional): Filter by fund status
|
||||
|
||||
**Response:** Array of `SnWalletFund` objects with `X-Total` header
|
||||
|
||||
**Authorization:** Required
|
||||
|
||||
---
|
||||
|
||||
### Get Fund
|
||||
**GET** `/api/wallets/funds/{id}`
|
||||
|
||||
Retrieves details of a specific fund.
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (Guid): Fund ID
|
||||
|
||||
**Response:** `SnWalletFund` object with recipients
|
||||
|
||||
**Authorization:** Required (user must be creator or recipient)
|
||||
|
||||
---
|
||||
|
||||
### Receive Fund
|
||||
**POST** `/api/wallets/funds/{id}/receive`
|
||||
|
||||
Claims the authenticated user's portion of a fund.
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (Guid): Fund ID
|
||||
|
||||
**Response:** `SnWalletTransaction` object
|
||||
|
||||
**Authorization:** Required (user must be a recipient)
|
||||
|
||||
## Service Methods
|
||||
|
||||
### Creating a Fund
|
||||
|
||||
```csharp
|
||||
// Service method
|
||||
public async Task<SnWalletFund> CreateFundAsync(
|
||||
Guid creatorAccountId,
|
||||
List<Guid> recipientAccountIds,
|
||||
string currency,
|
||||
decimal totalAmount,
|
||||
FundSplitType splitType,
|
||||
string? message = null,
|
||||
Duration? expiration = null)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `creatorAccountId`: Account ID of the fund creator
|
||||
- `recipientAccountIds`: List of recipient account IDs
|
||||
- `currency`: Currency type (e.g., "points", "golds")
|
||||
- `totalAmount`: Total amount to distribute
|
||||
- `splitType`: How to split the amount (Even/Random)
|
||||
- `message`: Optional message for the fund
|
||||
- `expiration`: Optional expiration duration (default: 24 hours)
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
creatorId: userId,
|
||||
recipientAccountIds: new List<Guid> { friend1Id, friend2Id, friend3Id },
|
||||
currency: "points",
|
||||
totalAmount: 100.00m,
|
||||
splitType: FundSplitType.Even,
|
||||
message: "Happy New Year!",
|
||||
expiration: Duration.FromHours(48) // Optional: 48 hours instead of default 24
|
||||
);
|
||||
```
|
||||
|
||||
### Claiming a Fund
|
||||
|
||||
```csharp
|
||||
// Service method
|
||||
public async Task<SnWalletTransaction> ReceiveFundAsync(
|
||||
Guid recipientAccountId,
|
||||
Guid fundId)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `recipientAccountId`: Account ID of the recipient claiming the fund
|
||||
- `fundId`: ID of the fund to claim from
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
var transaction = await paymentService.ReceiveFundAsync(
|
||||
recipientAccountId: myAccountId,
|
||||
fundId: fundId
|
||||
);
|
||||
```
|
||||
|
||||
## Split Logic
|
||||
|
||||
### Even Split
|
||||
Distributes the total amount equally among all recipients, handling decimal precision properly:
|
||||
|
||||
```csharp
|
||||
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100;
|
||||
var remainder = totalAmount - (baseAmount * recipientCount);
|
||||
|
||||
var amounts = new List<decimal>();
|
||||
for (int i = 0; i < recipientCount; i++)
|
||||
{
|
||||
var amount = baseAmount;
|
||||
if (i < remainder * 100)
|
||||
amount += 0.01m; // Distribute remainder as 0.01 increments
|
||||
amounts.Add(amount);
|
||||
}
|
||||
return amounts;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** 100.00 split among 3 recipients = [33.34, 33.33, 33.33]
|
||||
|
||||
### Random Split
|
||||
Generates random amounts that sum exactly to the total:
|
||||
|
||||
```csharp
|
||||
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var random = new Random();
|
||||
var amounts = new List<decimal>();
|
||||
decimal remaining = totalAmount;
|
||||
|
||||
for (int i = 0; i < recipientCount - 1; i++)
|
||||
{
|
||||
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
|
||||
var minAmount = 0.01m;
|
||||
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
|
||||
amounts.Add(amount);
|
||||
remaining -= amount;
|
||||
}
|
||||
|
||||
amounts.Add(Math.Round(remaining, 2)); // Last recipient gets remainder
|
||||
return amounts;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** 100.00 split randomly among 3 recipients = [45.67, 23.45, 30.88]
|
||||
|
||||
## Expiration and Refunds
|
||||
|
||||
### Automatic Processing
|
||||
Funds are processed hourly by the `FundExpirationJob`:
|
||||
|
||||
```csharp
|
||||
public async Task ProcessExpiredFundsAsync()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var expiredFunds = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.Where(f => f.Status == FundStatus.Created || f.Status == FundStatus.PartiallyReceived)
|
||||
.Where(f => f.ExpiredAt < now)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var fund in expiredFunds)
|
||||
{
|
||||
var unclaimedAmount = fund.Recipients
|
||||
.Where(r => !r.IsReceived)
|
||||
.Sum(r => r.Amount);
|
||||
|
||||
if (unclaimedAmount > 0)
|
||||
{
|
||||
// Refund to creator
|
||||
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
|
||||
if (creatorWallet != null)
|
||||
{
|
||||
await CreateTransactionAsync(
|
||||
payerWalletId: null,
|
||||
payeeWalletId: creatorWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: unclaimedAmount,
|
||||
remarks: $"Refund for expired fund {fund.Id}",
|
||||
type: TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fund.Status = FundStatus.Expired;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### Expiration Rules
|
||||
- Default expiration: 24 hours from creation
|
||||
- Custom expiration can be set when creating the fund
|
||||
- Only funds with status `Created` or `PartiallyReceived` are processed
|
||||
- Unclaimed amounts are refunded to the creator
|
||||
- Fund status changes to `Expired`
|
||||
|
||||
## Security & Validation
|
||||
|
||||
### Creation Validation
|
||||
- Creator must have sufficient funds
|
||||
- All recipient accounts must exist and have wallets
|
||||
- At least one recipient required
|
||||
- Total amount must be positive
|
||||
- Creator cannot be a recipient (self-transfer not allowed)
|
||||
|
||||
### Claim Validation
|
||||
- Fund must exist and not be expired/refunded
|
||||
- Recipient must be in the recipient list
|
||||
- Recipient can only claim once
|
||||
- Recipient must have a valid wallet
|
||||
|
||||
### Error Handling
|
||||
- `ArgumentException`: Invalid parameters
|
||||
- `InvalidOperationException`: Business logic violations
|
||||
- All errors provide descriptive messages
|
||||
|
||||
## Database Schema
|
||||
|
||||
### wallet_funds
|
||||
```sql
|
||||
CREATE TABLE wallet_funds (
|
||||
id UUID PRIMARY KEY,
|
||||
currency VARCHAR(128) NOT NULL,
|
||||
total_amount DECIMAL NOT NULL,
|
||||
split_type INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
message TEXT,
|
||||
creator_account_id UUID NOT NULL,
|
||||
expired_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
### wallet_fund_recipients
|
||||
```sql
|
||||
CREATE TABLE wallet_fund_recipients (
|
||||
id UUID PRIMARY KEY,
|
||||
fund_id UUID NOT NULL REFERENCES wallet_funds(id),
|
||||
recipient_account_id UUID NOT NULL,
|
||||
amount DECIMAL NOT NULL,
|
||||
is_received BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
received_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Wallet System
|
||||
- Funds are deducted from creator's wallet pocket immediately upon creation
|
||||
- Individual claims credit recipient's wallet pocket
|
||||
- Refunds credit creator's wallet pocket
|
||||
- All operations create audit transactions
|
||||
|
||||
### Notification System
|
||||
- Integrates with existing push notification system
|
||||
- Notifications sent for fund creation and claims
|
||||
- Uses localized messages for different languages
|
||||
|
||||
### Scheduled Jobs
|
||||
- `FundExpirationJob` runs every hour
|
||||
- Processes expired funds automatically
|
||||
- Handles refunds and status updates
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Red Packet for Group Event
|
||||
```csharp
|
||||
// Create a red packet for 5 friends totaling 500 points
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
creatorId,
|
||||
friendIds, // List of 5 friend account IDs
|
||||
"points",
|
||||
500.00m,
|
||||
FundSplitType.Random, // Lucky draw
|
||||
"Happy Birthday! 🎉"
|
||||
);
|
||||
```
|
||||
|
||||
### Equal Split Bonus Distribution
|
||||
```csharp
|
||||
// Distribute bonus equally among team members
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
managerId,
|
||||
teamMemberIds,
|
||||
"golds",
|
||||
1000.00m,
|
||||
FundSplitType.Even,
|
||||
"Monthly performance bonus"
|
||||
);
|
||||
```
|
||||
|
||||
### Claiming a Fund
|
||||
```csharp
|
||||
// User claims their portion
|
||||
try
|
||||
{
|
||||
var transaction = await paymentService.ReceiveFundAsync(userId, fundId);
|
||||
// Success - funds credited to user's wallet
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Handle error (already claimed, expired, not recipient, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Key Metrics
|
||||
- Total funds created per period
|
||||
- Claim rate (claimed vs expired)
|
||||
- Average expiration time
|
||||
- Popular split types
|
||||
|
||||
### Cleanup
|
||||
- Soft-deleted records are cleaned up by `AppDatabaseRecyclingJob`
|
||||
- Expired funds are processed by `FundExpirationJob`
|
||||
- No manual intervention required for normal operation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Fund Templates**: Pre-configured fund types
|
||||
- **Recurring Funds**: Scheduled fund distributions
|
||||
- **Fund Analytics**: Detailed usage statistics
|
||||
- **Fund Categories**: Tagging and categorization
|
||||
- **Bulk Operations**: Create funds for multiple groups
|
||||
- **Fund Forwarding**: Allow recipients to forward unclaimed portions
|
Reference in New Issue
Block a user