📝 Sort docs

This commit is contained in:
2025-12-28 22:24:22 +08:00
parent 95472df02b
commit 21108c19a9
19 changed files with 0 additions and 613 deletions

345
docs/GIFT_SUBSCRIPTIONS.md Normal file
View File

@@ -0,0 +1,345 @@
# Gift Subscriptions API Documentation
## Overview
The Gift Subscriptions feature allows users to purchase subscription gifts that can be redeemed by other users, enabling social gifting and subscription sharing within the DysonNetwork platform.
If you use it through the gateway, the `/api` should be replaced with the `/id`
### Key Features
- **Purchase Gifts**: Users can buy subscriptions as gifts for specific recipients or as open gifts
- **Gift Codes**: Each gift has a unique redemption code
- **Flexible Redemption**: Open gifts can be redeemed by anyone, while targeted gifts are recipient-specific
- **Security**: Prevents duplicate subscriptions and enforces account level requirements
- **Integration**: Full integration with existing subscription, coupon, and pricing systems
- **Clean User Experience**: Unpaid gifts are hidden from users and automatically cleaned up
- **Automatic Maintenance**: Old unpaid gifts are removed after 24 hours
## API Endpoints
All endpoints are authenticated and require a valid user session. The base path for gift endpoints is `/api/gifts`.
### 1. List Sent Gifts
Retrieve gifts you have purchased.
```http
GET /api/gifts/sent?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 2. List Received Gifts
Retrieve gifts sent to you or redeemed by you (for open gifts).
```http
GET /api/gifts/received?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 3. Get Specific Gift
Retrieve details for a specific gift.
```http
GET /api/gifts/{giftId}
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift
**Response**: `SnWalletGift` object
### 4. Check Gift Code
Validate if a gift code can be redeemed by the current user.
```http
GET /api/gifts/check/{giftCode}
Authorization: Bearer <token>
```
**Response**:
```json
{
"gift_code": "ABCD1234EFGH",
"subscription_identifier": "basic",
"can_redeem": true,
"error": null,
"message": "Happy birthday!"
}
```
### 5. Purchase a Gift
Create and purchase a gift subscription.
```http
POST /api/gifts/purchase
Authorization: Bearer <token>
Content-Type: application/json
{
"subscription_identifier": "premium",
"recipient_id": "550e8400-e29b-41d4-a716-446655440000", // Optional: null for open gifts
"payment_method": "in_app_wallet",
"payment_details": {
"currency": "irl"
},
"message": "Enjoy your premium subscription!", // Optional
"coupon": "SAVE20", // Optional
"gift_duration_days": 30, // Optional: defaults to 30
"subscription_duration_days": 30 // Optional: defaults to 30
}
```
**Response**: `SnWalletGift` object
### 6. Redeem a Gift
Redeem a gift code to create a subscription for yourself.
```http
POST /api/gifts/redeem
Authorization: Bearer <token>
Content-Type: application/json
{
"gift_code": "ABCD1234EFGH"
}
```
**Response**:
```json
{
"gift": { ... },
"subscription": { ... }
}
```
### 7. Mark Gift as Sent
Mark a gift as sent (ready for redemption).
```http
POST /api/gifts/{giftId}/send
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to mark as sent
### 8. Cancel a Gift
Cancel a gift before it has been redeemed.
```http
POST /api/gifts/{giftId}/cancel
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to cancel
## Usage Examples
### Client Implementation
Here are examples showing how to integrate gift subscriptions into your client application.
#### Example 1: Purchase a Gift for a Specific User
```javascript
async function purchaseGiftForFriend(subscriptionId, friendId, message) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
recipient_id: friendId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: message
})
});
const gift = await response.json();
return gift.gift_code; // Share this code with the friend
}
```
#### Example 2: Create an Open Gift
```javascript
async function createOpenGift(subscriptionId) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: 'Redeem this anywhere!'
// No recipient_id makes it an open gift
})
});
const gift = await response.json();
// Mark as sent to make it redeemable
await markGiftAsSent(gift.id);
return gift;
}
```
#### Example 3: Redeem a Gift Code
```javascript
async function redeemGiftCode(giftCode) {
// First, check if the gift can be redeemed
const checkResponse = await fetch(`/api/gifts/check/${giftCode}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const checkResult = await checkResponse.json();
if (!checkResult.canRedeem) {
throw new Error(checkResult.error);
}
// If valid, redeem it
const redeemResponse = await fetch('/api/gifts/redeem', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
gift_code: giftCode
})
});
const result = await redeemResponse.json();
return result.subscription; // The newly created subscription
}
```
#### Example 4: Display User's Gift History
```javascript
async function getGiftHistory() {
// Get gifts I sent
const sentResponse = await fetch('/api/gifts/sent', {
headers: { 'Authorization': `Bearer ${token}` }
});
const sentGifts = await sentResponse.json();
// Get gifts I received
const receivedResponse = await fetch('/api/gifts/received', {
headers: { 'Authorization': `Bearer ${token}` }
});
const receivedGifts = await receivedResponse.json();
return { sent: sentGifts, received: receivedGifts };
}
```
## Gift Status Lifecycle
Gifts follow this status lifecycle:
1. **Created**: Initially purchased, can be cancelled or marked as sent
- **Note**: Gifts in "Created" status are not visible to users and are automatically cleaned up after 24 hours if unpaid
2. **Sent**: Made available for redemption, can be cancelled
3. **Redeemed**: Successfully redeemed, creates a subscription
4. **Cancelled**: Permanently cancelled, refund may be processed
5. **Expired**: Expired without redemption
## Automatic Maintenance
The system includes automatic cleanup to maintain data integrity:
- **Unpaid Gift Cleanup**: Gifts that remain in "Created" status (unpaid) for more than 24 hours are automatically removed from the database
- **User Visibility**: Only gifts that have been successfully paid and sent are visible in user gift lists
- **Background Processing**: Cleanup runs hourly via scheduled jobs
This ensures a clean user experience while preventing accumulation of abandoned gift purchases.
## Validation Rules
### Purchase Validation
- Subscription must exist and be valid
- If coupon provided, it must be valid and applicable
- Recipient account must exist (if specified)
- User must meet level requirements for the subscription
### Redemption Validation
- Gift code must exist
- Gift must be in "Sent" status
- Gift must not be expired
- User must meet level requirements
- User must not already have an active subscription of the same type
- For targeted gifts, user must be the specified recipient
## Pricing & Payments
Gifts use the same pricing system as regular subscriptions:
- Base price from subscription template
- Coupon discounts applied
- Currency conversion as needed
- Payment processing through existing payment methods
## Notification Events
The system sends push notifications for:
- **gifts.redeemed**: When someone redeems your gift
- **gifts.claimed**: When the recipient redeems your targeted gift
Notifications include gift and subscription details for rich UI updates.
## Error Handling
Common error responses:
- `400 Bad Request`: Invalid parameters, validation failures
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Gift or subscription not found
- `409 Conflict`: Business logic violations (duplicate subscriptions, etc.)
## Integration Notes
### Database Schema
The feature adds a `wallet_gifts` table with relationships to:
- `accounts` (gifter, recipient, redeemer)
- `wallet_subscriptions` (created subscription)
- `wallet_coupons` (applied discounts)
### Backwards Compatibility
- No changes to existing subscription endpoints
- New gift-related endpoints are additive
- Existing payment flows remain unchanged
### Performance Considerations
- Gift codes are indexed for fast lookups
- Status filters optimize database queries
- Caching integrated with existing subscription caching
## Support
For implementation questions or issues, refer to the DysonNetwork API documentation or contact the development team.

403
docs/LOTTERY.md Normal file
View File

@@ -0,0 +1,403 @@
:bug# Lottery System API Documentation
## Overview
The DysonNetwork Lottery System provides a daily lottery where users can purchase tickets with custom number selections. Each day features a new draw with random winning numbers. Users purchase tickets using ISP (Dyson Network Points), with results announced each morning.
The API is handled by the DysonNetwork.Pass service. Which means if you use it with the Gateway the `/api` should be replaced with `/pass`
### Key Features
- **Daily Draws**: Automated draws at midnight UTC
- **Custom Number Selection**: Users choose 5 unique numbers (0-99) + 1 special number (0-99)
- **Flexible Pricing**: Base cost 10 ISP + extra ISP per multiplier (e.g., multiplier=2 costs 20 ISP)
- **Daily Limits**: One ticket purchase per user per day
- **Prize System**: Multiple prize tiers based on matches
- **Instant Payment**: Tickets purchased using in-app points
- **Historical Records**: Complete draw history and statistics
## Data Models
### LotteryDrawStatus Enum
```csharp
public enum LotteryDrawStatus
{
Pending = 0, // Ticket awaiting draw
Drawn = 1 // Ticket has been processed in draw
}
```
### SnLottery Model
```csharp
public class SnLottery : ModelBase
{
public Guid Id { get; set; }
public SnAccount Account { get; set; } = null!;
public Guid AccountId { get; set; }
public List<int> RegionOneNumbers { get; set; } = new(); // 5 numbers (0-99)
public int RegionTwoNumber { get; set; } // Special number (0-99)
public int Multiplier { get; set; } = 1; // Prize multiplier (≥1)
public LotteryDrawStatus DrawStatus { get; set; }
public DateTime? DrawDate { get; set; } // Date when drawn
}
```
### SnLotteryRecord Model
```csharp
public class SnLotteryRecord : ModelBase
{
public Guid Id { get; set; }
public DateTime DrawDate { get; set; }
public List<int> WinningRegionOneNumbers { get; set; } = new(); // 5 winning numbers
public int WinningRegionTwoNumber { get; set; } // Winning special number
public int TotalTickets { get; set; } // Total tickets processed
public int TotalPrizesAwarded { get; set; } // Number of winning tickets
public long TotalPrizeAmount { get; set; } // Total ISP prize amount
}
```
## Prize Structure
| Region 1 Matches | Base Prize (ISP) | Notes |
|-----------------|------------------|-------|
| 0 | 0 | No prize |
| 1 | 10 | Minimum win |
| 2 | 20 | Double minimum |
| 3 | 50 | Five times minimum |
| 4 | 100 | Ten times minimum |
| 5 | 1000 | Maximum prize |
**Special Number Bonus**: If Region 2 number matches, multiply any prize by 10x.
## API Endpoints
All endpoints require authentication via Bearer token.
### Purchase Ticket
**POST** `/api/lotteries`
Creates a lottery order and deducts ISP from user's wallet.
**Request Body:**
```json
{
"RegionOneNumbers": [5, 23, 47, 68, 89],
"RegionTwoNumber": 42,
"Multiplier": 1
}
```
**Response:**
```json
{
"id": "guid",
"accountId": "guid",
"createdAt": "2025-10-24T00:00:00Z",
"status": "Paid",
"currency": "isp",
"amount": 10,
"productIdentifier": "lottery"
}
```
**Validation Rules:**
- `RegionOneNumbers`: Exactly 5 unique integers between 0-99
- `RegionTwoNumber`: Single integer between 0-99
- `Multiplier`: Integer ≥ 1
- User can only purchase 1 ticket per day
**Pricing:**
- Base cost: 10 ISP
- Additional cost: (Multiplier - 1) × 10 ISP
- Total cost = (Multiplier × 10) ISP
### Get User Tickets
**GET** `/api/lotteries`
Retrieves user's lottery tickets with pagination.
**Query Parameters:**
- `offset` (optional, default 0): Page offset
- `limit` (optional, default 20, max 100): Items per page
**Response:**
```json
[
{
"id": "guid",
"regionOneNumbers": [5, 23, 47, 68, 89],
"regionTwoNumber": 42,
"multiplier": 1,
"drawStatus": "Pending",
"drawDate": null,
"createdAt": "2025-10-24T10:30:00Z"
}
]
```
**Response Headers:**
```
X-Total: 42 // Total number of user's tickets
```
### Get Specific Ticket
**GET** `/api/lotteries/{id}`
Retrieves a specific lottery ticket by ID.
**Response:**
Same structure as individual items from Get User Tickets.
**Error Responses:**
- `404 Not Found`: Ticket doesn't exist or user doesn't own it
### Get Lottery Records
**GET** `/api/lotteries/records`
Retrieves historical lottery draw results.
**Query Parameters:**
- `startDate` (optional): Filter by draw date (YYYY-MM-DD)
- `endDate` (optional): Filter by draw date (YYYY-MM-DD)
- `offset` (optional, default 0): Page offset
- `limit` (optional, default 20): Items per page
**Response:**
```json
[
{
"id": "guid",
"drawDate": "2025-10-24T00:00:00Z",
"winningRegionOneNumbers": [7, 15, 23, 46, 82],
"winningRegionTwoNumber": 19,
"totalTickets": 245,
"totalPrizesAwarded": 23,
"totalPrizeAmount": 4820
}
]
```
## Integration Examples
### Frontend Integration (JavaScript/React)
```javascript
// Purchase a lottery ticket
async function purchaseLottery(numbers, specialNumber, multiplier = 1) {
try {
const response = await fetch('/api/lotteries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`
},
body: JSON.stringify({
RegionOneNumbers: numbers, // Array of 5 unique numbers 0-99
RegionTwoNumber: specialNumber, // Number 0-99
Multiplier: multiplier // Optional, defaults to 1
})
});
const order = await response.json();
if (response.ok) {
console.log('Ticket purchased successfully!', order);
// Refresh user ISP balance
updateWalletBalance();
} else {
console.error('Purchase failed:', order);
}
} catch (error) {
console.error('Network error:', error);
}
}
// Get user's tickets
async function getUserTickets() {
try {
const response = await fetch('/api/lotteries?limit=20', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
const tickets = await response.json();
const totalTickets = response.headers.get('X-Total');
return { tickets, total: parseInt(totalTickets) };
} catch (error) {
console.error('Error fetching tickets:', error);
}
}
// Get draw history
async function getDrawHistory() {
try {
const response = await fetch('/api/lotteries/records', {
headers: {
'Authorization': `Bearer ${userToken}`
}
});
return await response.json();
} catch (error) {
console.error('Error fetching history:', error);
}
}
```
### Mobile Integration (React Native/TypeScript)
```typescript
interface LotteryTicket {
id: string;
regionOneNumbers: number[];
regionTwoNumber: number;
multiplier: number;
drawStatus: 'Pending' | 'Drawn';
drawDate?: string;
createdAt: string;
}
interface PurchaseRequest {
RegionOneNumbers: number[];
RegionTwoNumber: number;
Multiplier: number;
}
class LotteryService {
private apiUrl = 'https://your-api-domain.com/api/lotteries';
async purchaseTicket(
ticket: Omit<PurchaseRequest, 'RegionOneNumbers'> & { numbers: number[] },
token: string
): Promise<any> {
const request: PurchaseRequest = {
RegionOneNumbers: ticket.numbers,
RegionTwoNumber: ticket.RegionTwoNumber,
Multiplier: ticket.Multiplier
};
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(request)
});
return response.json();
}
async getTickets(token: string, offset = 0, limit = 20): Promise<LotteryTicket[]> {
const response = await fetch(`${this.apiUrl}?offset=${offset}&limit=${limit}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
async getDrawRecords(token: string): Promise<any[]> {
const response = await fetch(`${this.apiUrl}/records`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return response.json();
}
}
```
### Number Validation
```javascript
function validateLotteryNumbers(numbers, specialNumber, multiplier = 1) {
// Validate region one numbers
if (!Array.isArray(numbers) || numbers.length !== 5) {
return { valid: false, error: 'Must select exactly 5 numbers' };
}
const uniqueNumbers = new Set(numbers);
if (uniqueNumbers.size !== 5) {
return { valid: false, error: 'Numbers must be unique' };
}
// Check range 0-99
for (const num of numbers) {
if (!Number.isInteger(num) || num < 0 || num > 99) {
return { valid: false, error: 'Numbers must be integers between 0-99' };
}
}
// Validate special number
if (!Number.isInteger(specialNumber) || specialNumber < 0 || specialNumber > 99) {
return { valid: false, error: 'Special number must be between 0-99' };
}
// Validate multiplier
if (!Number.isInteger(multiplier) || multiplier < 1) {
return { valid: false, error: 'Multiplier must be 1 or greater' };
}
return { valid: true };
}
// Example usage
const validation = validateLotteryNumbers([5, 12, 23, 47, 89], 42, 2);
if (!validation.valid) {
console.error(validation.error);
}
```
## Daily Draw Schedule
- **Draw Time**: Every midnight UTC (00:00 UTC)
- **Processing**: Only tickets from the previous day are included
- **Prize Distribution**: Winners automatically receive ISP credits
- **History**: Draws are preserved indefinitely
## Error Handling
### Common Error Codes
- `400 Bad Request`: Invalid request data (bad numbers, duplicate purchase, etc.)
- `401 Unauthorized`: Missing or invalid authentication token
- `404 Not Found`: Ticket doesn't exist or access denied
- `403 Forbidden`: Insufficient permissions (admin endpoints)
### Error Response Format
```json
{
"message": "You can only purchase one lottery per day.",
"type": "ArgumentException",
"statusCode": 400
}
```
## Testing Guidelines
### Test Cases
1. **Valid Purchase**: Select valid numbers, verify wallet deduction
2. **Invalid Numbers**: Try duplicate region one numbers, out-of-range values
3. **Daily Limit**: Attempt second purchase in same day
4. **Insufficient Funds**: Try purchase without enough ISP
5. **Draw Processing**: Verify winning tickets receive correct prizes
6. **Historical Data**: Check draw records match processed tickets
### Test Data Examples
```javascript
// Valid ticket
{ numbers: [1, 15, 23, 67, 89], special: 42, multiplier: 1 }
// Invalid - duplicate numbers
{ numbers: [1, 15, 23, 15, 89], special: 42, multiplier: 1 }
// Invalid - out of range
{ numbers: [1, 15, 23, 67, 150], special: 42, multiplier: 1 }
```
## Support
For API integration questions or support:
- Check network documentation for authentication details
- Contact Dyson Network development team for assistance
- Monitor API response headers for pagination metadata

63
docs/ONBOARDING_FLOW.md Normal file
View File

@@ -0,0 +1,63 @@
# Client-Side Onboarding Flow for New Users
This document outlines the steps for a client application to handle the onboarding of new users who authenticate via a third-party provider.
## 1. Initiate the OIDC Login Flow
This step remains the same as a standard OIDC authorization code flow. The client application redirects the user to the `/authorize` endpoint of the authentication server with the required parameters (`response_type=code`, `client_id`, `redirect_uri`, `scope`, etc.).
## 2. Handle the Token Response
After the user authenticates with the third-party provider and is redirected back to the client, the client will have an `authorization_code`. The client then exchanges this code for tokens at the `/token` endpoint.
The response from the `/token` endpoint will differ for new and existing users.
### For Existing Users
If the user already has an account, the token response will be a standard OIDC token response, containing:
- `access_token`
- `id_token`
- `refresh_token`
- `expires_in`
- `token_type: "Bearer"`
The client should proceed with the standard login flow.
### For New Users
If the user is new, the token response will contain a special `onboarding_token`:
- `onboarding_token`: A JWT containing information about the new user from the external provider.
- `token_type: "Onboarding"`
The presence of the `onboarding_token` is the signal for the client to start the new user onboarding flow.
## 3. Process the Onboarding Token
The `onboarding_token` is a JWT. The client should decode it to access the claims, which will include:
- `provider`: The name of the external provider (e.g., "Google", "Facebook").
- `provider_user_id`: The user's unique ID from the external provider.
- `email`: The user's email address (if available).
- `name`: The user's full name from the external provider (if available).
- `nonce`: The nonce from the initial authorization request.
Using this information, the client can now guide the user through a custom onboarding process. For example, it can pre-fill a registration form with the user's name and email, and prompt the user to choose a unique username for their new account.
## 4. Complete the Onboarding
To finalize the account creation, the client needs to send the collected information to the server. This requires a new API endpoint on the server that is not part of this change.
**Example Endpoint:** `POST /api/account/onboard`
The client would send a request to this endpoint, including:
- The `onboarding_token`.
- The username chosen by the user.
- Any other required information.
The server will validate the `onboarding_token` and create a new user account with the provided details.
## 5. Finalize Login
Upon successful account creation, the server's onboarding endpoint should return a standard set of OIDC tokens (`access_token`, `id_token`, `refresh_token`) for the newly created user.
The client can then use these tokens to log the user in, completing the onboarding and login process.

View File

@@ -0,0 +1,324 @@
# Presence Activity API Documentation
## Overview
The Presence Activity API allows users to manage their current activities (e.g., gaming, music, workouts) with automatic expiration through a lease-based system. Activities can be created, updated, and deleted, with support for flexible metadata and both system-generated and user-defined identifiers.
This service is handled by the DysonNetwork.Pass, when using with the gateway, replace the `/api` with the `/pass`
## Key Features
- **Lease-Based Expiration**: Activities automatically expire within 1-60 minutes unless renewed
- **Flexible Identity**: Support for both autogenerated GUIDs and user-defined ManualIds
- **Extensible Metadata**: JSON-stored metadata dictionary for custom developer data
- **Soft Deletion**: Activities are soft-deleted and filtered automatically
- **Performance Optimized**: Cached active activities with 1-minute expiration
- **Authentication Required**: All endpoints require valid user authentication
## API Endpoints
### Base URL: `/api/activities`
### Authentication
All endpoints require `[Authorize]` header. User context is automatically extracted.
---
## Get Active Activities
Retrieve all currently active (non-expired) presence activities for the authenticated user.
**Endpoint:** `GET /api/activities`
**Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "Gaming",
"manualId": "game-session-1",
"title": "Playing Cyberpunk 2077",
"subtitle": "Night City Exploration",
"caption": "Missions completed: 15",
"meta": {
"appName": "Cyberpunk 2077",
"platform": "Steam",
"customProperty": "additional data"
},
"leaseMinutes": 10,
"leaseExpiresAt": "2024-01-15T14:30:00Z",
"accountId": "user-guid",
"createdAt": "2024-01-15T14:25:00Z",
"updatedAt": "2024-01-15T14:25:00Z",
"deletedAt": null
}
]
```
**Common Response Codes:**
- `200 OK` - Success, returns array of active activities
- `401 Unauthorized` - Invalid or missing authentication
---
## Create New Activity
Create a new presence activity with a configurable lease period.
**Endpoint:** `POST /api/activities`
**Request Body:**
```json
{
"type": "Gaming",
"manualId": "my-game-session",
"title": "Playing Cyberpunk 2077",
"subtitle": "Night City Mission",
"caption": "Currently exploring downtown",
"meta": {
"appName": "Cyberpunk 2077",
"platform": "Steam",
"difficulty": "Hard",
"mods": ["mod1", "mod2"]
},
"leaseMinutes": 15
}
```
**Response:** Returns the created `SnPresenceActivity` object with populated fields.
**Field Details:**
- `type`: PresenceType enum (Unknown, Gaming, Music, Workout)
- `manualId`: Optional user-defined string identifier
- `title`, `subtitle`, `caption`: Display strings (max 4096 chars each)
- `meta`: Optional `Dictionary<string, object>` for custom data
- `leaseMinutes`: 1-60 minutes (default: 5)
**Response Codes:**
- `200 OK` - Activity created successfully
- `400 Bad Request` - Invalid lease minutes or malformed data
- `401 Unauthorized` - Invalid authentication
---
## Update Activity
Update an existing activity using either its GUID or ManualId. Only provided fields are updated.
**Endpoint:** `PUT /api/activities`
**Query Parameters:** (one required)
- `id` - System-generated GUID (string)
- `manualId` - User-defined identifier (string)
**Request Body:** (all fields optional)
```json
{
"title": "Updated: Playing Cyberpunk 2077",
"meta": {
"appName": "Cyberpunk 2077",
"platform": "Steam",
"newProperty": "updated data"
},
"leaseMinutes": 20
}
```
**Response:** Returns the updated `SnPresenceActivity` object.
**Response Codes:**
- `200 OK` - Activity updated successfully
- `400 Bad Request` - Missing or invalid ID parameters
- `401 Unauthorized` - Invalid authentication
- `404 Not Found` - Activity not found or doesn't belong to user
**Example cURL:**
```bash
# Update by ManualId
curl -X PUT "/api/activities?manualId=my-game-session" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"leaseMinutes": 20}'
# Update by GUID
curl -X PUT "/api/activities?id=550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}'
```
---
## Delete Activity
Soft-delete an activity using either GUID or ManualId.
**Endpoint:** `DELETE /api/activities`
**Query Parameters:** (one required)
- `id` - System-generated GUID (string)
- `manualId` - User-defined identifier (string)
**Request Body:** None
**Response:** No content (204)
**Response Codes:**
- `204 No Content` - Activity deleted successfully
- `400 Bad Request` - Missing or invalid ID parameters
- `401 Unauthorized` - Invalid authentication
- `404 Not Found` - Activity not found or doesn't belong to user
**Example cURL:**
```bash
# Delete by ManualId
curl -X DELETE "/api/activities?manualId=my-game-session" \
-H "Authorization: Bearer <token>"
```
---
## Additional Endpoint
### Get Activities by Account ID
**Endpoint:** `GET /api/activities/{accountId:guid}`
For administrative or debugging purposes. Returns activities for the specified account ID, regardless of authentication.
---
## Data Models
### PresenceType Enum
```csharp
public enum PresenceType
{
Unknown,
Gaming,
Music,
Workout
}
```
### SnPresenceActivity
```csharp
public class SnPresenceActivity : ModelBase
{
public Guid Id { get; set; } // System-generated GUID
public PresenceType Type { get; set; }
public string? ManualId { get; set; } // User-defined ID
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Caption { get; set; }
public Dictionary<string, object>? Meta { get; set; } // JSON metadata
public int LeaseMinutes { get; set; } // Lease duration
public Instant LeaseExpiresAt { get; set; } // Expiration timestamp
// Inherited from ModelBase
public Guid AccountId { get; set; }
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; } // Soft deletion
}
```
## Behavior & Constraints
### Lease Expiration
- Activities automatically expire when `SystemClock.Instance.GetCurrentInstant() > LeaseExpiresAt`
- Expiry is checked in database queries, so expired activities are filtered out of GET operations
- Clients must periodically update/renew leases to keep activities active
### ID Flexibility
- **ManualId**: User-defined string, unique within a user's activities
- **GUID**: System-generated, always unique, returned in API responses
- Both can be used interchangeably for updates and deletion
### Performance Optimizations
- Active activities are cached for 1 minute to handle frequent updates
- Cache is invalidated on create/update/delete operations
- Database queries filter expired activities automatically
### Security
- All operations are scoped to the authenticated user's account
- Users can only manage their own activities
- Invalid or expired authentication tokens return 401 Unauthorized
### Data Storage
- Activities are stored in PostgreSQL with JSONB metadata support
- Soft deletion uses timestamp rather than hard removal
- EF Core middleware automatically handles CreatedAt/UpdatedAt timestamps
## Usage Examples
### Gaming Session Management
```javascript
// Start gaming session
const activity = await fetch('/api/activities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'Gaming',
manualId: 'game-session-1',
title: 'Playing Cyberpunk 2077',
meta: { appId: 'cyberpunk2077', mods: ['photorealistic'] },
leaseMinutes: 15
})
});
// Update progress (extend lease)
await fetch('/api/activities?manualId=game-session-1', {
method: 'PUT',
body: JSON.stringify({
title: 'Playing Cyberpunk 2077 - Level 25',
leaseMinutes: 15
})
});
// End session
await fetch('/api/activities?manualId=game-session-1', {
method: 'DELETE'
});
```
### Metadata Extension
```javascript
// Rich metadata support
const activity = {
type: 'Music',
manualId: 'spotify-session',
title: 'Listening to Electronic',
meta: {
spotifyTrackId: '1Je1IMUlBXcx1FzbcXRuWw',
artist: 'Purity Ring',
album: 'Shrines',
duration: 240000, // milliseconds
custom: { userRating: 5, genre: 'Electronic' }
},
leaseMinutes: 30
};
```
## Error Handling
Common error responses follow REST API conventions:
```json
{
"type": "Microsoft.AspNetCore.Mvc.ValidationProblemDetails",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"leaseMinutes": ["Lease minutes must be between 1 and 60."]
}
}
```
## Implementation Notes
- Built with ASP.NET Core and Entity Framework Core
- Uses NodaTime for precise timestamp handling
- PostgreSQL JSONB for flexible metadata storage
- Integration with existing authentication and caching systems
- Follows established project patterns for soft deletion and audit trails

429
docs/WALLET_FUNDS.md Normal file
View 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