Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/src/api-version/api-version.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';

@ApiTags('API Version')
@Controller({ path: 'api-version', version: VERSION_NEUTRAL })
export class ApiVersionController {
@Get()
@ApiOperation({ summary: 'Get current and supported API versions' })
@ApiResponse({ status: 200, description: 'API version metadata' })
getApiVersion() {
const current = process.env.API_VERSION || 'v1';
return {
current,
supported: [current],
deprecated: [],
buildInfo: {
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development',
},
};
}
}
7 changes: 7 additions & 0 deletions backend/src/api-version/api-version.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ApiVersionController } from './api-version.controller';

@Module({
controllers: [ApiVersionController],
})
export class ApiVersionModule {}
Comment on lines +4 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Import ApiVersionModule into the application root.

backend/src/app.module.ts:86-140 still omits this module, so Nest never mounts ApiVersionController and /api-version stays unreachable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/api-version/api-version.module.ts` around lines 4 - 7, App is not
importing ApiVersionModule so ApiVersionController never mounts; open the
application root module (AppModule) and add ApiVersionModule to the imports
array so Nest can register the controller and expose /api-version; reference the
ApiVersionModule symbol (and ApiVersionController for verification) when
updating the imports list in the AppModule.

35 changes: 35 additions & 0 deletions backend/src/fees/entities/unified-fee-configuration.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';

@Entity('fee_configurations')
@Index(['effectiveFrom'])
export class UnifiedFeeConfiguration {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'fee_percentage', type: 'decimal', precision: 5, scale: 2 })
feePercentage: number;

@Column({ name: 'minimum_fee_xlm', type: 'decimal', precision: 20, scale: 7, nullable: true })
minimumFeeXLM: number | null;

@Column({ name: 'maximum_fee_xlm', type: 'decimal', precision: 20, scale: 7, nullable: true })
maximumFeeXLM: number | null;

@Column({ name: 'waived_for_verified_artists', type: 'boolean', default: false })
waivedForVerifiedArtists: boolean;

@Column({ name: 'effective_from', type: 'timestamp' })
effectiveFrom: Date;

@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
62 changes: 62 additions & 0 deletions backend/src/fees/entities/unified-platform-fee.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Tip } from '../../tips/entities/tip.entity';

export enum FeeCollectionStatus {
PENDING = 'pending',
COLLECTED = 'collected',
WAIVED = 'waived',
}

@Entity('platform_fees')
@Index(['tipId'])
@Index(['collectionStatus'])
@Index(['createdAt'])
export class UnifiedPlatformFee {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'tip_id', type: 'uuid' })
tipId: string;

@ManyToOne(() => Tip, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tip_id' })
tip: Tip;

@Column({ name: 'fee_percentage', type: 'decimal', precision: 5, scale: 2 })
feePercentage: number;

@Column({ name: 'fee_amount_xlm', type: 'decimal', precision: 20, scale: 7 })
feeAmountXLM: number;

@Column({ name: 'fee_amount_usd', type: 'decimal', precision: 20, scale: 4, nullable: true })
feeAmountUSD: number | null;

@Column({
name: 'collection_status',
type: 'enum',
enum: FeeCollectionStatus,
default: FeeCollectionStatus.PENDING,
})
collectionStatus: FeeCollectionStatus;

@Column({ name: 'stellar_tx_hash', type: 'varchar', length: 64, nullable: true })
stellarTxHash: string | null;

@Column({ name: 'collected_at', type: 'timestamp', nullable: true })
collectedAt: Date | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
76 changes: 76 additions & 0 deletions backend/src/fees/fee-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Fee Domain Documentation

## Overview

This document describes the consolidated fee domain that unifies the previous `fees/` and `platinum-fee/` modules into a single, canonical fee management system.

## Domain Boundaries

### Core Responsibilities
- **Fee Configuration Management**: Historical and active fee configurations
- **Fee Calculation**: Platform fee computation with business rules
- **Fee Recording**: Persistent storage of fee records
- **Fee Collection**: Tracking collection status and reconciliation
- **Fee Analytics**: Ledger queries and financial summaries

### Key Entities

#### PlatformFee
Represents a fee charged on a tip transaction:
- `tipId`: Reference to the associated tip
- `feePercentage`: Percentage applied to calculate the fee
- `feeAmountXLM`: Fee amount in XLM (stored as decimal for precision)
- `feeAmountUSD`: Fee amount in USD (optional, for reporting)
- `collectionStatus`: PENDING | COLLECTED | WAIVED
- `stellarTxHash`: Transaction hash when fee is collected
- `collectedAt`: Timestamp when fee was collected

#### FeeConfiguration
Historical fee configurations with effective dates:
- `feePercentage`: Platform fee percentage
- `minimumFeeXLM`: Minimum fee per transaction
- `maximumFeeXLM`: Maximum fee per transaction
- `waivedForVerifiedArtists`: Whether verified artists are exempt
- `effectiveFrom`: Date when configuration becomes active
- `createdBy`: Admin who created the configuration

### Business Rules

1. **Historical Configurations**: Never overwrite configurations - create new records
2. **Effective Dating**: Use the most recent configuration with effectiveFrom <= now
3. **Verified Artist Waiver**: Verified artists may have fees waived based on configuration
4. **Fee Calculation**: Apply percentage, then enforce min/max bounds
5. **Collection Status**: Track fee collection lifecycle (pending → collected/waived)

### Integration Points

- **Tips Service**: Automatically records fees when tips are created
- **Stellar Service**: Handles fee collection transactions
- **Artist Service**: Provides verification status for fee waivers
- **Analytics**: Uses fee data for financial reporting

### Migration Strategy

1. **Entity Unification**: Use the more robust platinum-fee entity structure
2. **Service Consolidation**: Merge functionality from both services
3. **API Compatibility**: Maintain existing endpoints during transition
4. **Data Migration**: Ensure existing data is compatible with new schema
5. **Deprecation**: Mark old module for removal after validation

### API Endpoints

- `GET /fees/configuration` - Get active fee configuration
- `POST /fees/configuration` - Update fee configuration (admin)
- `GET /fees/configuration/history` - Get configuration history
- `GET /fees/ledger` - Get fee ledger with pagination
- `GET /fees/totals` - Get platform fee totals
- `GET /fees/artist/:artistId/summary` - Get artist fee summary
- `POST /fees/:feeId/collect` - Mark fee as collected (admin)

### Testing Strategy

- **Configuration Tests**: Verify historical configuration management
- **Calculation Tests**: Validate fee calculation logic
- **Recording Tests**: Ensure fee recording accuracy
- **Query Tests**: Test ledger queries and summaries
- **Integration Tests**: Verify end-to-end fee workflows
70 changes: 70 additions & 0 deletions backend/src/fees/unified-fee-calculator.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { UnifiedFeeConfiguration } from './entities/unified-fee-configuration.entity';

export interface FeeCalculationInput {
amountXLM: number;
xlmToUsdRate?: number;
isVerifiedArtist: boolean;
config: UnifiedFeeConfiguration;
}

export interface FeeCalculationResult {
feePercentage: number;
feeAmountXLM: number;
feeAmountUSD?: number;
isWaived: boolean;
}

@Injectable()
export class UnifiedFeeCalculatorService {
calculate(input: FeeCalculationInput): FeeCalculationResult {
const { amountXLM, xlmToUsdRate, isVerifiedArtist, config } = input;

// Check if fee should be waived for verified artists
if (isVerifiedArtist && config.waivedForVerifiedArtists) {
return {
feePercentage: config.feePercentage,
feeAmountXLM: 0,
feeAmountUSD: 0,
isWaived: true,
};
}

// Calculate fee amount
let feeAmountXLM = (amountXLM * config.feePercentage) / 100;

// Apply minimum fee constraint
if (config.minimumFeeXLM && feeAmountXLM < config.minimumFeeXLM) {
feeAmountXLM = config.minimumFeeXLM;
}

// Apply maximum fee constraint
if (config.maximumFeeXLM && feeAmountXLM > config.maximumFeeXLM) {
feeAmountXLM = config.maximumFeeXLM;
}

// Calculate USD amount if rate is provided
let feeAmountUSD: number | undefined;
if (xlmToUsdRate) {
feeAmountUSD = feeAmountXLM * xlmToUsdRate;
}

return {
feePercentage: config.feePercentage,
feeAmountXLM,
feeAmountUSD,
isWaived: false,
};
}

parsePeriodToDate(period: string): Date {
const now = new Date();
const value = parseInt(period, 10);

if (isNaN(value) || value <= 0) {
throw new Error(`Invalid period: ${period}. Expected positive integer.`);
}

return new Date(now.getTime() - value * 24 * 60 * 60 * 1000);
}
}
109 changes: 109 additions & 0 deletions backend/src/fees/unified-fees.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
Controller,
Get,
Post,
Put,
Query,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CurrentUserData } from '../auth/decorators/current-user.decorator';
import { UnifiedFeesService, UpdateFeeConfigDto, FeeLedgerQueryDto } from './unified-fees.service';

@ApiTags('Platform Fees')
@Controller('fees')
export class UnifiedFeesController {
constructor(private readonly feesService: UnifiedFeesService) {}

@Get('configuration')
@ApiOperation({ summary: 'Get active fee configuration' })
@ApiResponse({ status: 200, description: 'Active fee configuration' })
async getActiveConfiguration() {
return this.feesService.getActiveConfiguration();
}

@Get('configuration/history')
@ApiOperation({ summary: 'Get fee configuration history' })
@ApiResponse({ status: 200, description: 'Historical fee configurations' })
async getConfigurationHistory() {
return this.feesService.getConfigurationHistory();
}

@Post('configuration')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update fee configuration (admin only)' })
@ApiResponse({ status: 201, description: 'Fee configuration created' })
@ApiResponse({ status: 400, description: 'Invalid configuration' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async updateConfiguration(
@Body() updateConfigDto: UpdateFeeConfigDto,
@CurrentUser() user: CurrentUserData,
) {
return this.feesService.updateConfiguration(updateConfigDto, user.id);
Comment on lines +51 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use the actual current-user field name here.

CurrentUserData exposes userId (or @CurrentUser('id')), not id, so this is a compile failure and currently passes undefined into updateConfiguration.

Suggested change
-  async updateConfiguration(
-    `@Body`() updateConfigDto: UpdateFeeConfigDto,
-    `@CurrentUser`() user: CurrentUserData,
-  ) {
-    return this.feesService.updateConfiguration(updateConfigDto, user.id);
+  async updateConfiguration(
+    `@Body`() updateConfigDto: UpdateFeeConfigDto,
+    `@CurrentUser`('id') userId: string,
+  ) {
+    return this.feesService.updateConfiguration(updateConfigDto, userId);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async updateConfiguration(
@Body() updateConfigDto: UpdateFeeConfigDto,
@CurrentUser() user: CurrentUserData,
) {
return this.feesService.updateConfiguration(updateConfigDto, user.id);
async updateConfiguration(
`@Body`() updateConfigDto: UpdateFeeConfigDto,
`@CurrentUser`('id') userId: string,
) {
return this.feesService.updateConfiguration(updateConfigDto, userId);
}
🧰 Tools
🪛 GitHub Actions: Backend CI

[error] 55-55: TS2339: Property 'id' does not exist on type 'CurrentUserData' (this.feesService.updateConfiguration(..., user.id)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/fees/unified-fees.controller.ts` around lines 51 - 55, The
controller passes the wrong property from the CurrentUser object into
feesService.updateConfiguration: CurrentUserData exposes userId (not id), so
updateConfiguration(`@Body`() updateConfigDto, `@CurrentUser`() user:
CurrentUserData) should pass user.userId (or change the decorator to
`@CurrentUser`('id') to extract the id directly). Update the call to
feesService.updateConfiguration to use the correct identifier (user.userId) or
adjust the CurrentUser decorator to return the id so the method receives the
actual current-user id.

}
Comment on lines +44 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

admin only routes are only authenticated, not authorized.

JwtAuthGuard only checks that a JWT is present; it does not enforce admin privileges. Right now any authenticated user can change the platform fee configuration or mark fees as collected.

Also applies to: 94-107

🧰 Tools
🪛 GitHub Actions: Backend CI

[error] 55-55: TS2339: Property 'id' does not exist on type 'CurrentUserData' (this.feesService.updateConfiguration(..., user.id)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/fees/unified-fees.controller.ts` around lines 44 - 56, The routes
(e.g., the updateConfiguration method) are only protected by JwtAuthGuard so any
authenticated user can call admin-only endpoints; add an authorization guard and
role check to enforce admin privileges. Update the controller methods that
currently use JwtAuthGuard (including updateConfiguration and the other fee
endpoints) to use a role-based guard (e.g., RolesGuard or a PoliciesGuard) and
annotate them with an admin role requirement (e.g., `@Roles`('admin') or
equivalent) so the guard checks CurrentUser role/permissions before calling
feesService methods; ensure the guard is registered via `@UseGuards` alongside
JwtAuthGuard and that the role decorator name matches your existing auth
implementation (Roles, AdminOnly, etc.).


@Get('ledger')
@ApiOperation({ summary: 'Get fee ledger with pagination' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'period', required: false, type: String })
@ApiResponse({ status: 200, description: 'Paginated fee ledger' })
async getFeeLedger(@Query() query: FeeLedgerQueryDto) {
return this.feesService.getFeeLedger(query);
}

@Get('totals')
@ApiOperation({ summary: 'Get platform fee totals' })
@ApiQuery({ name: 'period', required: false, type: String })
@ApiResponse({ status: 200, description: 'Platform fee totals and statistics' })
async getPlatformTotals(@Query('period') period?: string) {
return this.feesService.getPlatformTotals(period);
}

@Get('artist/:artistId/summary')
@ApiOperation({ summary: 'Get fee summary for a specific artist' })
@ApiParam({ name: 'artistId', description: 'Artist ID' })
@ApiResponse({ status: 200, description: 'Artist fee summary' })
@ApiResponse({ status: 404, description: 'Artist not found' })
async getArtistFeeSummary(@Param('artistId', ParseUUIDPipe) artistId: string) {
return this.feesService.getArtistFeeSummary(artistId);
}

@Get('tip/:tipId')
@ApiOperation({ summary: 'Get fee record for a specific tip' })
@ApiParam({ name: 'tipId', description: 'Tip ID' })
@ApiResponse({ status: 200, description: 'Fee record for tip' })
@ApiResponse({ status: 404, description: 'Fee record not found' })
async getFeeByTipId(@Param('tipId', ParseUUIDPipe) tipId: string) {
return this.feesService.getFeeByTipId(tipId);
}

@Put(':feeId/collect')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Mark fee as collected (admin only)' })
@ApiParam({ name: 'feeId', description: 'Fee ID' })
@ApiResponse({ status: 200, description: 'Fee marked as collected' })
@ApiResponse({ status: 400, description: 'Cannot collect waived fee' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Fee not found' })
async markFeeCollected(
@Param('feeId', ParseUUIDPipe) feeId: string,
@Body('stellarTxHash') stellarTxHash: string,
) {
return this.feesService.markFeeCollected(feeId, stellarTxHash);
}
}
Loading
Loading