diff --git a/backend/src/api-version/api-version.controller.ts b/backend/src/api-version/api-version.controller.ts new file mode 100644 index 0000000..3c9a631 --- /dev/null +++ b/backend/src/api-version/api-version.controller.ts @@ -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', + }, + }; + } +} diff --git a/backend/src/api-version/api-version.module.ts b/backend/src/api-version/api-version.module.ts new file mode 100644 index 0000000..cfa1835 --- /dev/null +++ b/backend/src/api-version/api-version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { ApiVersionController } from './api-version.controller'; + +@Module({ + controllers: [ApiVersionController], +}) +export class ApiVersionModule {} diff --git a/backend/src/fees/entities/unified-fee-configuration.entity.ts b/backend/src/fees/entities/unified-fee-configuration.entity.ts new file mode 100644 index 0000000..007f289 --- /dev/null +++ b/backend/src/fees/entities/unified-fee-configuration.entity.ts @@ -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; +} diff --git a/backend/src/fees/entities/unified-platform-fee.entity.ts b/backend/src/fees/entities/unified-platform-fee.entity.ts new file mode 100644 index 0000000..7171c01 --- /dev/null +++ b/backend/src/fees/entities/unified-platform-fee.entity.ts @@ -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; +} diff --git a/backend/src/fees/fee-domain.md b/backend/src/fees/fee-domain.md new file mode 100644 index 0000000..f6b74ee --- /dev/null +++ b/backend/src/fees/fee-domain.md @@ -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 diff --git a/backend/src/fees/unified-fee-calculator.service.ts b/backend/src/fees/unified-fee-calculator.service.ts new file mode 100644 index 0000000..17722d0 --- /dev/null +++ b/backend/src/fees/unified-fee-calculator.service.ts @@ -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); + } +} diff --git a/backend/src/fees/unified-fees.controller.ts b/backend/src/fees/unified-fees.controller.ts new file mode 100644 index 0000000..6d3e76d --- /dev/null +++ b/backend/src/fees/unified-fees.controller.ts @@ -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); + } + + @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); + } +} diff --git a/backend/src/fees/unified-fees.module.ts b/backend/src/fees/unified-fees.module.ts new file mode 100644 index 0000000..b089e48 --- /dev/null +++ b/backend/src/fees/unified-fees.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UnifiedPlatformFee } from './entities/unified-platform-fee.entity'; +import { UnifiedFeeConfiguration } from './entities/unified-fee-configuration.entity'; +import { UnifiedFeesService } from './unified-fees.service'; +import { UnifiedFeeCalculatorService } from './unified-fee-calculator.service'; +import { UnifiedFeesController } from './unified-fees.controller'; +import { StellarModule } from '../stellar/stellar.module'; +import { ArtistsModule } from '../artists/artists.module'; +import { TipsModule } from '../tips/tips.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([UnifiedPlatformFee, UnifiedFeeConfiguration]), + StellarModule, + ArtistsModule, + TipsModule, + ], + controllers: [UnifiedFeesController], + providers: [UnifiedFeesService, UnifiedFeeCalculatorService], + exports: [UnifiedFeesService, UnifiedFeeCalculatorService], +}) +export class UnifiedFeesModule {} diff --git a/backend/src/fees/unified-fees.service.spec.ts b/backend/src/fees/unified-fees.service.spec.ts new file mode 100644 index 0000000..1fb737e --- /dev/null +++ b/backend/src/fees/unified-fees.service.spec.ts @@ -0,0 +1,372 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository, DataSource } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UnifiedFeesService } from './unified-fees.service'; +import { UnifiedFeeCalculatorService } from './unified-fee-calculator.service'; +import { StellarService } from '../stellar/stellar.service'; +import { UnifiedPlatformFee, FeeCollectionStatus } from './entities/unified-platform-fee.entity'; +import { UnifiedFeeConfiguration } from './entities/unified-fee-configuration.entity'; +import { Artist } from '../artists/entities/artist.entity'; +import { Tip } from '../tips/entities/tip.entity'; + +describe('UnifiedFeesService', () => { + let service: UnifiedFeesService; + let platformFeeRepo: Repository; + let feeConfigRepo: Repository; + let artistRepo: Repository; + let stellarService: StellarService; + let feeCalculator: UnifiedFeeCalculatorService; + let dataSource: DataSource; + + const mockTip: Tip = { + id: 'tip-123', + amount: '10.5', + assetCode: 'XLM', + assetIssuer: null, + stellarTxHash: 'tx-hash-123', + artistId: 'artist-123', + sender: { id: 'user-123' } as any, + artist: { id: 'artist-123', isVerified: false } as any, + } as Tip; + + const mockArtist: Artist = { + id: 'artist-123', + isVerified: true, + walletAddress: 'GD123456789', + user: { id: 'user-456' } as any, + } as Artist; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UnifiedFeesService, + UnifiedFeeCalculatorService, + { + provide: getRepositoryToken(UnifiedPlatformFee), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UnifiedFeeConfiguration), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Artist), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: StellarService, + useValue: { + getConversionRate: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: { + query: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UnifiedFeesService); + platformFeeRepo = module.get>( + getRepositoryToken(UnifiedPlatformFee), + ); + feeConfigRepo = module.get>( + getRepositoryToken(UnifiedFeeConfiguration), + ); + artistRepo = module.get>(getRepositoryToken(Artist)); + stellarService = module.get(StellarService); + feeCalculator = module.get(UnifiedFeeCalculatorService); + dataSource = module.get(DataSource); + }); + + describe('getActiveConfiguration', () => { + it('should return active configuration', async () => { + const config = new UnifiedFeeConfiguration(); + config.feePercentage = 2.5; + config.effectiveFrom = new Date('2024-01-01'); + + jest.spyOn(feeConfigRepo, 'findOne').mockResolvedValue(config); + + const result = await service.getActiveConfiguration(); + + expect(feeConfigRepo.findOne).toHaveBeenCalledWith({ + where: { effectiveFrom: expect.any(Date) }, + order: { effectiveFrom: 'DESC' }, + }); + expect(result).toEqual(config); + }); + + it('should return default configuration when none exists', async () => { + jest.spyOn(feeConfigRepo, 'findOne').mockResolvedValue(null); + + const result = await service.getActiveConfiguration(); + + expect(result.feePercentage).toBe(2.5); + expect(result.minimumFeeXLM).toBe(0.1); + expect(result.maximumFeeXLM).toBe(100); + expect(result.waivedForVerifiedArtists).toBe(false); + }); + }); + + describe('updateConfiguration', () => { + it('should create new fee configuration', async () => { + const updateDto = { + feePercentage: 3.0, + minimumFeeXLM: 0.2, + maximumFeeXLM: 50, + waivedForVerifiedArtists: true, + }; + const adminUserId = 'admin-123'; + + const newConfig = new UnifiedFeeConfiguration(); + jest.spyOn(feeConfigRepo, 'create').mockReturnValue(newConfig); + jest.spyOn(feeConfigRepo, 'save').mockResolvedValue(newConfig); + + const result = await service.updateConfiguration(updateDto, adminUserId); + + expect(feeConfigRepo.create).toHaveBeenCalledWith({ + feePercentage: 3.0, + minimumFeeXLM: 0.2, + maximumFeeXLM: 50, + waivedForVerifiedArtists: true, + effectiveFrom: expect.any(Date), + createdBy: adminUserId, + }); + expect(feeConfigRepo.save).toHaveBeenCalledWith(newConfig); + expect(result).toBe(newConfig); + }); + + it('should throw error when minimum exceeds maximum', async () => { + const updateDto = { + feePercentage: 3.0, + minimumFeeXLM: 100, + maximumFeeXLM: 50, + }; + + await expect( + service.updateConfiguration(updateDto, 'admin-123'), + ).rejects.toThrow('minimumFeeXLM cannot exceed maximumFeeXLM'); + }); + }); + + describe('recordFeeForTip', () => { + it('should record fee for XLM tip', async () => { + const config = new UnifiedFeeConfiguration(); + config.feePercentage = 2.5; + config.waivedForVerifiedArtists = false; + + const calculationResult = { + feePercentage: 2.5, + feeAmountXLM: 0.2625, + feeAmountUSD: 0.0525, + isWaived: false, + }; + + jest.spyOn(service, 'getActiveConfiguration').mockResolvedValue(config); + jest.spyOn(artistRepo, 'findOne').mockResolvedValue(mockArtist); + jest.spyOn(feeCalculator, 'calculate').mockReturnValue(calculationResult); + + const fee = new UnifiedPlatformFee(); + jest.spyOn(platformFeeRepo, 'create').mockReturnValue(fee); + jest.spyOn(platformFeeRepo, 'save').mockResolvedValue(fee); + + const result = await service.recordFeeForTip(mockTip); + + expect(service.getActiveConfiguration).toHaveBeenCalled(); + expect(artistRepo.findOne).toHaveBeenCalledWith({ where: { id: mockTip.artistId } }); + expect(feeCalculator.calculate).toHaveBeenCalledWith({ + amountXLM: 10.5, + isVerifiedArtist: true, + config, + }); + expect(platformFeeRepo.create).toHaveBeenCalledWith({ + tipId: mockTip.id, + feePercentage: 2.5, + feeAmountXLM: 0.2625, + feeAmountUSD: 0.0525, + collectionStatus: FeeCollectionStatus.PENDING, + stellarTxHash: mockTip.stellarTxHash, + }); + expect(platformFeeRepo.save).toHaveBeenCalledWith(fee); + expect(result).toBe(fee); + }); + + it('should waive fee for verified artist when configured', async () => { + const config = new UnifiedFeeConfiguration(); + config.feePercentage = 2.5; + config.waivedForVerifiedArtists = true; + + const calculationResult = { + feePercentage: 2.5, + feeAmountXLM: 0, + feeAmountUSD: 0, + isWaived: true, + }; + + jest.spyOn(service, 'getActiveConfiguration').mockResolvedValue(config); + jest.spyOn(artistRepo, 'findOne').mockResolvedValue(mockArtist); + jest.spyOn(feeCalculator, 'calculate').mockReturnValue(calculationResult); + + const fee = new UnifiedPlatformFee(); + jest.spyOn(platformFeeRepo, 'create').mockReturnValue(fee); + jest.spyOn(platformFeeRepo, 'save').mockResolvedValue(fee); + + const result = await service.recordFeeForTip(mockTip); + + expect(platformFeeRepo.create).toHaveBeenCalledWith({ + tipId: mockTip.id, + feePercentage: 2.5, + feeAmountXLM: 0, + feeAmountUSD: 0, + collectionStatus: FeeCollectionStatus.WAIVED, + stellarTxHash: mockTip.stellarTxHash, + }); + }); + }); + + describe('markFeeCollected', () => { + it('should mark fee as collected', async () => { + const fee = new UnifiedPlatformFee(); + fee.id = 'fee-123'; + fee.collectionStatus = FeeCollectionStatus.PENDING; + + jest.spyOn(platformFeeRepo, 'findOne').mockResolvedValue(fee); + jest.spyOn(platformFeeRepo, 'save').mockResolvedValue(fee); + + const result = await service.markFeeCollected('fee-123', 'new-tx-hash'); + + expect(platformFeeRepo.findOne).toHaveBeenCalledWith({ where: { id: 'fee-123' } }); + expect(fee.collectionStatus).toBe(FeeCollectionStatus.COLLECTED); + expect(fee.stellarTxHash).toBe('new-tx-hash'); + expect(fee.collectedAt).toBeInstanceOf(Date); + expect(platformFeeRepo.save).toHaveBeenCalledWith(fee); + expect(result).toBe(fee); + }); + + it('should throw error when fee not found', async () => { + jest.spyOn(platformFeeRepo, 'findOne').mockResolvedValue(null); + + await expect( + service.markFeeCollected('fee-123', 'new-tx-hash'), + ).rejects.toThrow('PlatformFee fee-123 not found'); + }); + + it('should throw error when trying to collect waived fee', async () => { + const fee = new UnifiedPlatformFee(); + fee.id = 'fee-123'; + fee.collectionStatus = FeeCollectionStatus.WAIVED; + + jest.spyOn(platformFeeRepo, 'findOne').mockResolvedValue(fee); + + await expect( + service.markFeeCollected('fee-123', 'new-tx-hash'), + ).rejects.toThrow('Cannot collect a waived fee'); + }); + }); + + describe('getFeeByTipId', () => { + it('should return fee by tip ID', async () => { + const fee = new UnifiedPlatformFee(); + fee.tipId = 'tip-123'; + + jest.spyOn(platformFeeRepo, 'findOne').mockResolvedValue(fee); + + const result = await service.getFeeByTipId('tip-123'); + + expect(platformFeeRepo.findOne).toHaveBeenCalledWith({ + where: { tipId: 'tip-123' }, + relations: ['tip'], + }); + expect(result).toBe(fee); + }); + + it('should throw error when fee not found', async () => { + jest.spyOn(platformFeeRepo, 'findOne').mockResolvedValue(null); + + await expect( + service.getFeeByTipId('tip-123'), + ).rejects.toThrow('No fee record found for tip tip-123'); + }); + }); + + describe('getPlatformTotals', () => { + it('should return platform totals', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + totalFeesXLM: '100.50', + totalFeesUSD: '20.10', + totalCollected: '80.40', + totalPending: '20.10', + totalWaived: '0.00', + totalTransactions: '50', + averageFeeXLM: '2.01', + averageFeePercentage: '2.5', + }), + }; + + jest.spyOn(platformFeeRepo, 'createQueryBuilder').mockReturnValue(mockQueryBuilder); + + const result = await service.getPlatformTotals(); + + expect(result).toEqual({ + totalFeesXLM: 100.50, + totalFeesUSD: 20.10, + totalCollected: 80.40, + totalPending: 20.10, + totalWaived: 0.00, + totalTransactions: 50, + averageFeeXLM: 2.01, + averageFeePercentage: 2.5, + }); + }); + }); + + describe('getArtistFeeSummary', () => { + it('should return artist fee summary', async () => { + const mockQueryResult = [{ + totalTips: '10', + totalFeesXLM: '25.50', + totalFeesUSD: '5.10', + waivedCount: '2', + collectedCount: '8', + pendingCount: '0', + }]; + + jest.spyOn(dataSource, 'query').mockResolvedValue(mockQueryResult); + + const result = await service.getArtistFeeSummary('artist-123'); + + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining('SELECT'), + ['artist-123'], + ); + expect(result).toEqual({ + artistId: 'artist-123', + totalFeesXLM: 25.50, + totalFeesUSD: 5.10, + waivedCount: 2, + collectedCount: 8, + pendingCount: 0, + totalTips: 10, + }); + }); + }); +}); diff --git a/backend/src/fees/unified-fees.service.ts b/backend/src/fees/unified-fees.service.ts new file mode 100644 index 0000000..06db2b7 --- /dev/null +++ b/backend/src/fees/unified-fees.service.ts @@ -0,0 +1,335 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + Repository, + DataSource, + MoreThanOrEqual, + LessThanOrEqual, +} from 'typeorm'; +import { UnifiedPlatformFee, FeeCollectionStatus } from './entities/unified-platform-fee.entity'; +import { UnifiedFeeConfiguration } from './entities/unified-fee-configuration.entity'; +import { UnifiedFeeCalculatorService, FeeCalculationInput } from './unified-fee-calculator.service'; +import { StellarService } from '../stellar/stellar.service'; +import { Artist } from '../artists/entities/artist.entity'; +import { Tip } from '../tips/entities/tip.entity'; + +export interface RecordFeeInput { + tipId: string; + amountXLM: number; + xlmToUsdRate?: number; + isVerifiedArtist: boolean; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface UpdateFeeConfigDto { + feePercentage: number; + minimumFeeXLM?: number | null; + maximumFeeXLM?: number | null; + waivedForVerifiedArtists?: boolean; + effectiveFrom?: string; +} + +export interface FeeLedgerQueryDto { + page?: number; + limit?: number; + period?: string; +} + +@Injectable() +export class UnifiedFeesService { + private readonly logger = new Logger(UnifiedFeesService.name); + + constructor( + @InjectRepository(UnifiedPlatformFee) + private readonly platformFeeRepo: Repository, + @InjectRepository(UnifiedFeeConfiguration) + private readonly feeConfigRepo: Repository, + @InjectRepository(Artist) + private readonly artistRepo: Repository, + private readonly feeCalculator: UnifiedFeeCalculatorService, + private readonly stellarService: StellarService, + private readonly dataSource: DataSource, + ) {} + + // ─── Configuration Management ───────────────────────────────────────────────────── + + async getActiveConfiguration(): Promise { + const config = await this.feeConfigRepo.findOne({ + where: { effectiveFrom: LessThanOrEqual(new Date()) }, + order: { effectiveFrom: 'DESC' }, + }); + + if (!config) { + // Return sensible defaults if no config exists + const defaults = new UnifiedFeeConfiguration(); + defaults.feePercentage = 2.5; + defaults.minimumFeeXLM = 0.1; + defaults.maximumFeeXLM = 100; + defaults.waivedForVerifiedArtists = false; + defaults.effectiveFrom = new Date(0); + defaults.createdBy = 'system'; + return defaults; + } + + return config; + } + + async updateConfiguration( + dto: UpdateFeeConfigDto, + adminUserId: string, + ): Promise { + // Validate min/max relationship + if (dto.minimumFeeXLM && dto.maximumFeeXLM && dto.minimumFeeXLM > dto.maximumFeeXLM) { + throw new BadRequestException('minimumFeeXLM cannot exceed maximumFeeXLM'); + } + + // Always create a new record — never overwrite historical configs + const newConfig = this.feeConfigRepo.create({ + feePercentage: dto.feePercentage, + minimumFeeXLM: dto.minimumFeeXLM, + maximumFeeXLM: dto.maximumFeeXLM, + waivedForVerifiedArtists: dto.waivedForVerifiedArtists ?? false, + effectiveFrom: dto.effectiveFrom ? new Date(dto.effectiveFrom) : new Date(), + createdBy: adminUserId, + }); + + const saved = await this.feeConfigRepo.save(newConfig); + this.logger.log( + `Fee configuration updated by admin ${adminUserId}: ${JSON.stringify(saved)}`, + ); + return saved; + } + + async getConfigurationHistory(): Promise { + return this.feeConfigRepo.find({ order: { effectiveFrom: 'DESC' } }); + } + + // ─── Fee Recording ─────────────────────────────────────────────────────────────── + + async recordFeeForTip(tip: Tip): Promise { + const [config, artist] = await Promise.all([ + this.getActiveConfiguration(), + this.artistRepo.findOne({ where: { id: tip.artistId } }), + ]); + + const isVerifiedArtist = (artist as any)?.isVerified === true; + + let convertedAmountXLM: number | null = null; + let xlmToUsdRate: number | undefined; + + if (tip.assetCode !== 'XLM') { + try { + const conversion = await this.stellarService.getConversionRate( + tip.assetCode, + tip.assetIssuer || null, + 'XLM', + null, + tip.amount, + ); + const estimated = conversion.estimatedAmount; + const estimatedStr = + typeof estimated === 'string' ? estimated : estimated.toString(); + convertedAmountXLM = parseFloat(estimatedStr); + + // Get USD rate for fee calculation + const usdConversion = await this.stellarService.getConversionRate( + 'XLM', + null, + 'USD', + null, + convertedAmountXLM, + ); + const usdEstimated = usdConversion.estimatedAmount; + const usdEstimatedStr = + typeof usdEstimated === 'string' ? usdEstimated : usdEstimated.toString(); + xlmToUsdRate = parseFloat(usdEstimatedStr) / convertedAmountXLM; + } catch { + convertedAmountXLM = null; + xlmToUsdRate = undefined; + } + } + + const tipAmountXLM = convertedAmountXLM || parseFloat(tip.amount.toString()); + + const input: FeeCalculationInput = { + amountXLM: tipAmountXLM, + xlmToUsdRate, + isVerifiedArtist, + config, + }; + + const result = this.feeCalculator.calculate(input); + + const fee = this.platformFeeRepo.create({ + tipId: tip.id, + feePercentage: result.feePercentage, + feeAmountXLM: result.feeAmountXLM, + feeAmountUSD: result.feeAmountUSD, + collectionStatus: result.isWaived + ? FeeCollectionStatus.WAIVED + : FeeCollectionStatus.PENDING, + stellarTxHash: tip.stellarTxHash, + }); + + return this.platformFeeRepo.save(fee); + } + + async markFeeCollected( + feeId: string, + stellarTxHash: string, + ): Promise { + const fee = await this.platformFeeRepo.findOne({ where: { id: feeId } }); + if (!fee) throw new NotFoundException(`PlatformFee ${feeId} not found`); + if (fee.collectionStatus === FeeCollectionStatus.WAIVED) { + throw new BadRequestException('Cannot collect a waived fee'); + } + + fee.collectionStatus = FeeCollectionStatus.COLLECTED; + fee.stellarTxHash = stellarTxHash; + fee.collectedAt = new Date(); + return this.platformFeeRepo.save(fee); + } + + // ─── Query Operations ─────────────────────────────────────────────────────────── + + async getFeeByTipId(tipId: string): Promise { + const fee = await this.platformFeeRepo.findOne({ + where: { tipId }, + relations: ['tip'], + }); + if (!fee) { + throw new NotFoundException(`No fee record found for tip ${tipId}`); + } + return fee; + } + + async getFeeLedger( + query: FeeLedgerQueryDto, + ): Promise> { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + const skip = (page - 1) * limit; + + const qb = this.platformFeeRepo.createQueryBuilder('fee') + .leftJoinAndSelect('fee.tip', 'tip'); + + if (query.period) { + const since = this.feeCalculator.parsePeriodToDate(query.period); + qb.where('fee.createdAt >= :since', { since }); + } + + qb.orderBy('fee.createdAt', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getPlatformTotals(period?: string): Promise<{ + totalFeesXLM: number; + totalFeesUSD: number; + totalCollected: number; + totalPending: number; + totalWaived: number; + totalTransactions: number; + averageFeeXLM: number; + averageFeePercentage: number; + }> { + const qb = this.platformFeeRepo + .createQueryBuilder('fee') + .select('SUM(CAST(fee.feeAmountXLM AS DECIMAL))', 'totalFeesXLM') + .addSelect('SUM(CAST(fee.feeAmountUSD AS DECIMAL))', 'totalFeesUSD') + .addSelect( + `SUM(CASE WHEN fee.collectionStatus = 'collected' THEN CAST(fee.feeAmountXLM AS DECIMAL) ELSE 0 END)`, + 'totalCollected', + ) + .addSelect( + `SUM(CASE WHEN fee.collectionStatus = 'pending' THEN CAST(fee.feeAmountXLM AS DECIMAL) ELSE 0 END)`, + 'totalPending', + ) + .addSelect( + `SUM(CASE WHEN fee.collectionStatus = 'waived' THEN CAST(fee.feeAmountXLM AS DECIMAL) ELSE 0 END)`, + 'totalWaived', + ) + .addSelect('COUNT(*)', 'totalTransactions') + .addSelect('AVG(CAST(fee.feeAmountXLM AS DECIMAL))', 'averageFeeXLM') + .addSelect( + 'AVG(CAST(fee.feePercentage AS DECIMAL))', + 'averageFeePercentage', + ); + + if (period) { + const since = this.feeCalculator.parsePeriodToDate(period); + qb.where('fee.createdAt >= :since', { since }); + } + + const raw = await qb.getRawOne(); + + return { + totalFeesXLM: parseFloat(raw.totalFeesXLM ?? '0'), + totalFeesUSD: parseFloat(raw.totalFeesUSD ?? '0'), + totalCollected: parseFloat(raw.totalCollected ?? '0'), + totalPending: parseFloat(raw.totalPending ?? '0'), + totalWaived: parseFloat(raw.totalWaived ?? '0'), + totalTransactions: parseInt(raw.totalTransactions ?? '0', 10), + averageFeeXLM: parseFloat(raw.averageFeeXLM ?? '0'), + averageFeePercentage: parseFloat(raw.averageFeePercentage ?? '0'), + }; + } + + async getArtistFeeSummary(artistId: string): Promise<{ + artistId: string; + totalFeesXLM: number; + totalFeesUSD: number; + waivedCount: number; + collectedCount: number; + pendingCount: number; + totalTips: number; + }> { + // Join through tips table to get artist-specific fees + const raw = await this.dataSource.query( + ` + SELECT + COUNT(pf.id) AS "totalTips", + SUM(CAST(pf.fee_amount_xlm AS DECIMAL)) AS "totalFeesXLM", + SUM(CAST(pf.fee_amount_usd AS DECIMAL)) AS "totalFeesUSD", + SUM(CASE WHEN pf.collection_status = 'waived' THEN 1 ELSE 0 END) AS "waivedCount", + SUM(CASE WHEN pf.collection_status = 'collected' THEN 1 ELSE 0 END) AS "collectedCount", + SUM(CASE WHEN pf.collection_status = 'pending' THEN 1 ELSE 0 END) AS "pendingCount" + FROM platform_fees pf + INNER JOIN tips t ON t.id = pf.tip_id + WHERE t.artist_id = $1 + `, + [artistId], + ); + + const row = raw[0] ?? {}; + return { + artistId, + totalFeesXLM: parseFloat(row.totalFeesXLM ?? '0'), + totalFeesUSD: parseFloat(row.totalFeesUSD ?? '0'), + waivedCount: parseInt(row.waivedCount ?? '0', 10), + collectedCount: parseInt(row.collectedCount ?? '0', 10), + pendingCount: parseInt(row.pendingCount ?? '0', 10), + totalTips: parseInt(row.totalTips ?? '0', 10), + }; + } +} diff --git a/backend/src/redis-platform/redis-platform.health.controller.ts b/backend/src/redis-platform/redis-platform.health.controller.ts new file mode 100644 index 0000000..c4129bc --- /dev/null +++ b/backend/src/redis-platform/redis-platform.health.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RedisPlatformService } from './redis-platform.service'; + +@ApiTags('Redis Platform Health') +@Controller({ path: 'redis-platform/health', version: VERSION_NEUTRAL }) +export class RedisPlatformHealthController { + constructor(private readonly redisService: RedisPlatformService) {} + + @Get() + @ApiOperation({ summary: 'Check Redis platform health' }) + @ApiResponse({ status: 200, description: 'Redis health status' }) + async getHealth() { + return this.redisService.healthCheck(); + } + + @Get('metrics') + @ApiOperation({ summary: 'Get Redis platform metrics' }) + @ApiResponse({ status: 200, description: 'Redis metrics and statistics' }) + async getMetrics() { + return this.redisService.getMetrics(); + } + + @Get('status') + @ApiOperation({ summary: 'Get Redis platform connection status' }) + @ApiResponse({ status: 200, description: 'Redis connection status' }) + async getStatus() { + return { + status: this.redisService.getStatus(), + ready: this.redisService.isReady(), + }; + } +} diff --git a/backend/src/redis-platform/redis-platform.module.ts b/backend/src/redis-platform/redis-platform.module.ts new file mode 100644 index 0000000..05e273c --- /dev/null +++ b/backend/src/redis-platform/redis-platform.module.ts @@ -0,0 +1,118 @@ +import { DynamicModule, Global, Logger, Module, Provider } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export const REDIS_PLATFORM_CLIENT = 'REDIS_PLATFORM_CLIENT'; +export const REDIS_PLATFORM_OPTIONS = 'REDIS_PLATFORM_OPTIONS'; + +export interface RedisPlatformOptions { + host: string; + port: number; + password?: string; + db?: number; + keyPrefix?: string; + connectTimeout?: number; + maxRetriesPerRequest?: number; + enableReadyCheck?: boolean; + lazyConnect?: boolean; +} + +@Global() +@Module({}) +export class RedisPlatformModule { + private static readonly logger = new Logger(RedisPlatformModule.name); + + static forRoot(options: RedisPlatformOptions): DynamicModule { + const optionsProvider: Provider = { + provide: REDIS_PLATFORM_OPTIONS, + useValue: options, + }; + + const clientProvider: Provider = { + provide: REDIS_PLATFORM_CLIENT, + useFactory: () => RedisPlatformModule.createClient(options), + }; + + return { + module: RedisPlatformModule, + imports: [], + providers: [optionsProvider, clientProvider], + exports: [REDIS_PLATFORM_CLIENT, REDIS_PLATFORM_OPTIONS], + }; + } + + static forRootAsync(): DynamicModule { + const clientProvider: Provider = { + provide: REDIS_PLATFORM_CLIENT, + inject: [ConfigService], + useFactory: (config: ConfigService) => { + const options: RedisPlatformOptions = { + host: config.get('REDIS_HOST', 'localhost'), + port: config.get('REDIS_PORT', 6379), + password: config.get('REDIS_PASSWORD'), + db: config.get('REDIS_DB', 0), + keyPrefix: config.get('REDIS_KEY_PREFIX', 'tip-tune:'), + connectTimeout: config.get('REDIS_CONNECT_TIMEOUT', 5_000), + maxRetriesPerRequest: config.get('REDIS_MAX_RETRIES', 3), + enableReadyCheck: true, + lazyConnect: false, + }; + return RedisPlatformModule.createClient(options); + }, + }; + + return { + module: RedisPlatformModule, + imports: [ConfigModule], + providers: [clientProvider], + exports: [REDIS_PLATFORM_CLIENT], + }; + } + + private static createClient(options: RedisPlatformOptions): Redis { + const client = new Redis({ + host: options.host, + port: options.port, + password: options.password, + db: options.db ?? 0, + keyPrefix: options.keyPrefix ?? '', + connectTimeout: options.connectTimeout ?? 5_000, + maxRetriesPerRequest: options.maxRetriesPerRequest ?? 3, + enableReadyCheck: options.enableReadyCheck ?? true, + lazyConnect: options.lazyConnect ?? false, + }); + + // Enhanced logging and monitoring + client.on('connect', () => + RedisPlatformModule.logger.log(`Redis platform connected → ${options.host}:${options.port}`), + ); + + client.on('ready', () => + RedisPlatformModule.logger.log('Redis platform client ready'), + ); + + client.on('error', (err: Error) => + RedisPlatformModule.logger.error(`Redis platform error: ${err.message}`, err.stack), + ); + + client.on('close', () => + RedisPlatformModule.logger.warn('Redis platform connection closed'), + ); + + client.on('reconnecting', (delay: number) => + RedisPlatformModule.logger.warn(`Redis platform reconnecting in ${delay}ms`), + ); + + // Health check and metrics + client.on('connect', async () => { + try { + const info = await client.info('server'); + RedisPlatformModule.logger.debug('Redis server info:', info); + } catch (err) { + RedisPlatformModule.logger.warn('Failed to get Redis info:', err); + } + }); + + return client; + } +} diff --git a/backend/src/redis-platform/redis-platform.service.spec.ts b/backend/src/redis-platform/redis-platform.service.spec.ts new file mode 100644 index 0000000..d60fa8f --- /dev/null +++ b/backend/src/redis-platform/redis-platform.service.spec.ts @@ -0,0 +1,257 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RedisPlatformService } from './redis-platform.service'; +import { REDIS_PLATFORM_CLIENT } from './redis-platform.module'; +import Redis from 'ioredis'; + +describe('RedisPlatformService', () => { + let service: RedisPlatformService; + let redis: jest.Mocked; + + beforeEach(async () => { + const mockRedis = { + get: jest.fn(), + set: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + expire: jest.fn(), + ttl: jest.fn(), + hget: jest.fn(), + hset: jest.fn(), + hgetall: jest.fn(), + hdel: jest.fn(), + sadd: jest.fn(), + srem: jest.fn(), + sismember: jest.fn(), + smembers: jest.fn(), + lpush: jest.fn(), + rpush: jest.fn(), + lpop: jest.fn(), + rpop: jest.fn(), + llen: jest.fn(), + ping: jest.fn(), + info: jest.fn(), + incr: jest.fn(), + keys: jest.fn(), + flushdb: jest.fn(), + quit: jest.fn(), + disconnect: jest.fn(), + status: 'ready', + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisPlatformService, + { + provide: REDIS_PLATFORM_CLIENT, + useValue: mockRedis, + }, + ], + }).compile(); + + service = module.get(RedisPlatformService); + redis = mockRedis; + }); + + describe('Basic Operations', () => { + it('should get value', async () => { + redis.get.mockResolvedValue('test-value'); + + const result = await service.get('test-key'); + + expect(redis.get).toHaveBeenCalledWith('test-key'); + expect(result).toBe('test-value'); + }); + + it('should set value without TTL', async () => { + redis.set.mockResolvedValue('OK'); + + const result = await service.set('test-key', 'test-value'); + + expect(redis.set).toHaveBeenCalledWith('test-key', 'test-value'); + expect(result).toBe('OK'); + }); + + it('should set value with TTL', async () => { + redis.setex.mockResolvedValue('OK'); + + const result = await service.set('test-key', 'test-value', 3600); + + expect(redis.setex).toHaveBeenCalledWith('test-key', 3600, 'test-value'); + expect(result).toBe('OK'); + }); + + it('should delete key', async () => { + redis.del.mockResolvedValue(1); + + const result = await service.del('test-key'); + + expect(redis.del).toHaveBeenCalledWith('test-key'); + expect(result).toBe(1); + }); + + it('should check if key exists', async () => { + redis.exists.mockResolvedValue(1); + + const result = await service.exists('test-key'); + + expect(redis.exists).toHaveBeenCalledWith('test-key'); + expect(result).toBe(1); + }); + }); + + describe('Cache Abstractions', () => { + it('should cache get with JSON parsing', async () => { + redis.get.mockResolvedValue('{"key":"value"}'); + + const result = await service.cacheGet<{key: string}>('test-key'); + + expect(redis.get).toHaveBeenCalledWith('test-key'); + expect(result).toEqual({ key: 'value' }); + }); + + it('should cache get with invalid JSON returns null', async () => { + redis.get.mockResolvedValue('invalid-json'); + + const result = await service.cacheGet('test-key'); + + expect(result).toBeNull(); + }); + + it('should cache set with JSON serialization', async () => { + redis.setex.mockResolvedValue('OK'); + + await service.cacheSet('test-key', { key: 'value' }, 3600); + + expect(redis.setex).toHaveBeenCalledWith('test-key', 3600, '{"key":"value"}'); + }); + + it('should cache delete', async () => { + redis.del.mockResolvedValue(1); + + await service.cacheDel('test-key'); + + expect(redis.del).toHaveBeenCalledWith('test-key'); + }); + }); + + describe('Throttler Integration', () => { + it('should increment throttler and set expiry on first call', async () => { + redis.incr.mockResolvedValue(1); + redis.expire.mockResolvedValue(1); + + const result = await service.incrementThrottler('test-key', 60); + + expect(redis.incr).toHaveBeenCalledWith('test-key'); + expect(redis.expire).toHaveBeenCalledWith('test-key', 60); + expect(result).toBe(1); + }); + + it('should increment throttler without expiry on subsequent calls', async () => { + redis.incr.mockResolvedValue(2); + + const result = await service.incrementThrottler('test-key', 60); + + expect(redis.incr).toHaveBeenCalledWith('test-key'); + expect(redis.expire).not.toHaveBeenCalled(); + expect(result).toBe(2); + }); + + it('should reset throttler', async () => { + redis.del.mockResolvedValue(1); + + await service.resetThrottler('test-key'); + + expect(redis.del).toHaveBeenCalledWith('test-key'); + }); + }); + + describe('Health Check', () => { + it('should return healthy status on successful ping', async () => { + redis.ping.mockResolvedValue('PONG'); + const startSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + const endSpy = jest.spyOn(Date, 'now').mockReturnValue(1010); + + const result = await service.healthCheck(); + + expect(redis.ping).toHaveBeenCalled(); + expect(result).toEqual({ + status: 'healthy', + responseTime: 10, + }); + + startSpy.mockRestore(); + endSpy.mockRestore(); + }); + + it('should return unhealthy status on ping failure', async () => { + redis.ping.mockRejectedValue(new Error('Connection failed')); + const startSpy = jest.spyOn(Date, 'now').mockReturnValue(1000); + const endSpy = jest.spyOn(Date, 'now').mockReturnValue(1050); + + const result = await service.healthCheck(); + + expect(result).toEqual({ + status: 'unhealthy', + responseTime: 50, + error: 'Connection failed', + }); + + startSpy.mockRestore(); + endSpy.mockRestore(); + }); + }); + + describe('Metrics', () => { + it('should parse Redis info and return metrics', async () => { + const mockInfo = ` +# Memory +used_memory:1048576 +used_memory_human:1M +# Stats +total_commands_processed:1000 +keyspace_hits:800 +keyspace_misses:200 +# Clients +connected_clients:5 + `; + + redis.info.mockResolvedValue(mockInfo); + + const result = await service.getMetrics(); + + expect(result).toEqual({ + connectedClients: 5, + usedMemory: '1M', + totalCommandsProcessed: 1000, + keyspaceHits: 800, + keyspaceMisses: 200, + hitRate: 80, + }); + }); + + it('should handle Redis info parsing errors', async () => { + redis.info.mockRejectedValue(new Error('Info command failed')); + + await expect(service.getMetrics()).rejects.toThrow('Info command failed'); + }); + }); + + describe('Connection Management', () => { + it('should check if ready', () => { + redis.status = 'ready'; + + expect(service.isReady()).toBe(true); + + redis.status = 'connecting'; + + expect(service.isReady()).toBe(false); + }); + + it('should get status', () => { + redis.status = 'ready'; + + expect(service.getStatus()).toBe('ready'); + }); + }); +}); diff --git a/backend/src/redis-platform/redis-platform.service.ts b/backend/src/redis-platform/redis-platform.service.ts new file mode 100644 index 0000000..c46ed18 --- /dev/null +++ b/backend/src/redis-platform/redis-platform.service.ts @@ -0,0 +1,257 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Inject } from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS_PLATFORM_CLIENT } from './redis-platform.module'; + +export interface RedisHealthResult { + status: 'healthy' | 'unhealthy'; + responseTime: number; + error?: string; +} + +export interface RedisMetrics { + connectedClients: number; + usedMemory: string; + totalCommandsProcessed: number; + keyspaceHits: number; + keyspaceMisses: number; + hitRate: number; +} + +@Injectable() +export class RedisPlatformService { + private readonly logger = new Logger(RedisPlatformService.name); + + constructor(@Inject(REDIS_PLATFORM_CLIENT) private readonly redis: Redis) {} + + // ─── Basic Operations ───────────────────────────────────────────────────────────── + + async get(key: string): Promise { + return this.redis.get(key); + } + + async set(key: string, value: string, ttl?: number): Promise<'OK'> { + if (ttl) { + return this.redis.setex(key, ttl, value); + } + return this.redis.set(key, value); + } + + async del(key: string): Promise { + return this.redis.del(key); + } + + async exists(key: string): Promise { + return this.redis.exists(key); + } + + async expire(key: string, ttl: number): Promise { + return this.redis.expire(key, ttl); + } + + async ttl(key: string): Promise { + return this.redis.ttl(key); + } + + // ─── Hash Operations ───────────────────────────────────────────────────────────── + + async hget(key: string, field: string): Promise { + return this.redis.hget(key, field); + } + + async hset(key: string, field: string, value: string): Promise { + return this.redis.hset(key, field, value); + } + + async hgetall(key: string): Promise> { + return this.redis.hgetall(key); + } + + async hdel(key: string, field: string): Promise { + return this.redis.hdel(key, field); + } + + // ─── Set Operations ───────────────────────────────────────────────────────────── + + async sadd(key: string, member: string): Promise { + return this.redis.sadd(key, member); + } + + async srem(key: string, member: string): Promise { + return this.redis.srem(key, member); + } + + async sismember(key: string, member: string): Promise { + return this.redis.sismember(key, member); + } + + async smembers(key: string): Promise { + return this.redis.smembers(key); + } + + // ─── List Operations ───────────────────────────────────────────────────────────── + + async lpush(key: string, value: string): Promise { + return this.redis.lpush(key, value); + } + + async rpush(key: string, value: string): Promise { + return this.redis.rpush(key, value); + } + + async lpop(key: string): Promise { + return this.redis.lpop(key); + } + + async rpop(key: string): Promise { + return this.redis.rpop(key); + } + + async llen(key: string): Promise { + return this.redis.llen(key); + } + + // ─── Health and Metrics ───────────────────────────────────────────────────────── + + async healthCheck(): Promise { + const start = Date.now(); + try { + await this.redis.ping(); + const responseTime = Date.now() - start; + return { + status: 'healthy', + responseTime, + }; + } catch (error) { + const responseTime = Date.now() - start; + return { + status: 'unhealthy', + responseTime, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + async getMetrics(): Promise { + try { + const info = await this.redis.info('memory,stats,clients'); + + const parseInfo = (info: string, section: string) => { + const lines = info.split('\r\n'); + const sectionStart = lines.findIndex(line => line.startsWith(`#${section}`)); + const sectionEnd = lines.findIndex((line, index) => + index > sectionStart && line.startsWith('#') + ); + + const sectionLines = sectionEnd > -1 + ? lines.slice(sectionStart + 1, sectionEnd) + : lines.slice(sectionStart + 1); + + const result: Record = {}; + sectionLines.forEach(line => { + if (line && !line.startsWith('#')) { + const [key, value] = line.split(':'); + if (key && value) { + result[key] = value; + } + } + }); + + return result; + }; + + const memoryInfo = parseInfo(info, 'Memory'); + const statsInfo = parseInfo(info, 'Stats'); + const clientsInfo = parseInfo(info, 'Clients'); + + const keyspaceHits = parseInt(statsInfo.keyspace_hits || '0', 10); + const keyspaceMisses = parseInt(statsInfo.keyspace_misses || '0', 10); + const totalCommands = parseInt(statsInfo.total_commands_processed || '0', 10); + const hitRate = keyspaceHits + keyspaceMisses > 0 + ? (keyspaceHits / (keyspaceHits + keyspaceMisses)) * 100 + : 0; + + return { + connectedClients: parseInt(clientsInfo.connected_clients || '0', 10), + usedMemory: memoryInfo.used_memory_human || '0B', + totalCommandsProcessed: totalCommands, + keyspaceHits, + keyspaceMisses, + hitRate: Math.round(hitRate * 100) / 100, + }; + } catch (error) { + this.logger.error('Failed to get Redis metrics:', error); + throw error; + } + } + + // ─── Cache Abstractions ───────────────────────────────────────────────────────── + + async cacheGet(key: string): Promise { + const value = await this.get(key); + if (!value) return null; + + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + + async cacheSet(key: string, value: T, ttl?: number): Promise { + const serialized = JSON.stringify(value); + await this.set(key, serialized, ttl); + } + + async cacheDel(key: string): Promise { + await this.del(key); + } + + // ─── Throttler Storage Integration ─────────────────────────────────────────────── + + async incrementThrottler(key: string, ttl: number): Promise { + const current = await this.redis.incr(key); + if (current === 1) { + await this.redis.expire(key, ttl); + } + return current; + } + + async resetThrottler(key: string): Promise { + await this.del(key); + } + + // ─── Utilities ───────────────────────────────────────────────────────────────────── + + async clearPattern(pattern: string): Promise { + const keys = await this.redis.keys(pattern); + if (keys.length === 0) return 0; + return this.redis.del(...keys); + } + + async getKeysByPattern(pattern: string): Promise { + return this.redis.keys(pattern); + } + + async flushDb(): Promise<'OK'> { + return this.redis.flushdb(); + } + + // ─── Connection Management ───────────────────────────────────────────────────────── + + async quit(): Promise<'OK'> { + return this.redis.quit(); + } + + async disconnect(): Promise { + this.redis.disconnect(); + } + + isReady(): boolean { + return this.redis.status === 'ready'; + } + + getStatus(): string { + return this.redis.status; + } +} diff --git a/backend/src/tips/tip-verification-queue.module.ts b/backend/src/tips/tip-verification-queue.module.ts new file mode 100644 index 0000000..a6bb610 --- /dev/null +++ b/backend/src/tips/tip-verification-queue.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { BullMQModule } from '@nestjs/bullmq'; +import { TipVerificationJob } from './tip-verification.job'; +import { TipsModule } from './tips.module'; +import { UnifiedFeesModule } from '../fees/unified-fees.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { ModerationModule } from '../moderation/moderation.module'; + +@Module({ + imports: [ + BullMQModule.registerQueue({ + name: 'tip-verification', + defaultJobOptions: { + removeOnComplete: 100, + removeOnFail: 50, + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }, + }), + TipsModule, + UnifiedFeesModule, + StellarModule, + NotificationsModule, + ModerationModule, + ], + providers: [TipVerificationJob], + exports: [TipVerificationJob], +}) +export class TipVerificationQueueModule {} diff --git a/backend/src/tips/tip-verification.job.ts b/backend/src/tips/tip-verification.job.ts new file mode 100644 index 0000000..0103524 --- /dev/null +++ b/backend/src/tips/tip-verification.job.ts @@ -0,0 +1,272 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job, JobHandler } from 'bullmq'; +import { Tip, TipStatus } from './entities/tip.entity'; +import { StellarService } from '../stellar/stellar.service'; +import { UnifiedFeesService } from '../fees/unified-fees.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { ModerationService } from '../moderation/moderation.service'; +import { TipReconciliationService } from './tip-reconciliation.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TipVerifiedEvent } from './events/tip-verified.event'; +import { NotificationType } from '../notifications/notification.entity'; + +export interface TipVerificationJobData { + tipId: string; + retryCount?: number; + maxRetries?: number; +} + +@Injectable() +export class TipVerificationJob implements JobHandler { + private readonly logger = new Logger(TipVerificationJob.name); + + constructor( + @InjectRepository(Tip) + private readonly tipRepository: Repository, + private readonly stellarService: StellarService, + private readonly feesService: UnifiedFeesService, + private readonly notificationsService: NotificationsService, + private readonly moderationService: ModerationService, + private readonly reconciliationService: TipReconciliationService, + private readonly eventEmitter: EventEmitter2, + ) {} + + async process(job: Job): Promise { + const { tipId, retryCount = 0, maxRetries = 3 } = job.data; + + this.logger.log(`Processing tip verification for tip ${tipId}, attempt ${retryCount + 1}/${maxRetries}`); + + try { + // Get tip with all necessary relations + const tip = await this.tipRepository.findOne({ + where: { id: tipId }, + relations: ['sender', 'artist', 'artist.user'], + }); + + if (!tip) { + this.logger.error(`Tip ${tipId} not found for verification`); + return; + } + + // Skip if already verified + if (tip.status === TipStatus.VERIFIED) { + this.logger.log(`Tip ${tipId} already verified, skipping`); + return; + } + + // Check moderation status + const moderationResult = await this.moderationService.checkTip(tip); + if (!moderationResult.allowed) { + this.logger.warn(`Tip ${tipId} blocked by moderation: ${moderationResult.reason}`); + await this.handleModerationBlock(tip, moderationResult.reason); + return; + } + + // Verify Stellar transaction + const verificationResult = await this.verifyStellarTransaction(tip); + + if (!verificationResult.verified) { + if (retryCount < maxRetries) { + this.logger.warn(`Tip ${tipId} verification failed, retrying: ${verificationResult.error}`); + await this.scheduleRetry(job, retryCount + 1); + return; + } else { + this.logger.error(`Tip ${tipId} verification failed after ${maxRetries} attempts: ${verificationResult.error}`); + await this.handleVerificationFailure(tip, verificationResult.error); + return; + } + } + + // Mark tip as verified + await this.markTipVerified(tip, verificationResult); + + // Record platform fee + await this.feesService.recordFeeForTip(tip); + + // Send notifications + await this.sendVerificationNotifications(tip); + + // Emit verification event + this.eventEmitter.emit('tip.verified', new TipVerifiedEvent(tip)); + + this.logger.log(`Tip ${tipId} successfully verified`); + + } catch (error) { + this.logger.error(`Error processing tip verification for ${tipId}:`, error); + + if (retryCount < maxRetries) { + await this.scheduleRetry(job, retryCount + 1); + } else { + await this.handleVerificationFailure({ id: tipId } as Tip, error.message); + } + } + } + + private async verifyStellarTransaction(tip: Tip): Promise<{ + verified: boolean; + error?: string; + transactionDetails?: any; + }> { + try { + if (!tip.stellarTxHash) { + return { verified: false, error: 'No transaction hash provided' }; + } + + const transaction = await this.stellarService.getTransaction(tip.stellarTxHash); + + if (!transaction) { + return { verified: false, error: 'Transaction not found on network' }; + } + + // Verify transaction details + const isValid = await this.validateTransactionDetails(tip, transaction); + + if (!isValid) { + return { verified: false, error: 'Transaction details do not match tip record' }; + } + + return { verified: true, transactionDetails: transaction }; + + } catch (error) { + return { + verified: false, + error: error instanceof Error ? error.message : 'Unknown verification error' + }; + } + } + + private async validateTransactionDetails(tip: Tip, transaction: any): Promise { + try { + // Check if transaction is successful + if (transaction.successful !== true) { + return false; + } + + // Check if transaction involves the correct asset and amount + const payment = transaction.operations?.find((op: any) => op.type === 'payment'); + + if (!payment) { + return false; + } + + // Validate amount + const expectedAmount = parseFloat(tip.amount.toString()); + const actualAmount = parseFloat(payment.amount); + + if (Math.abs(expectedAmount - actualAmount) > 0.0000001) { + return false; + } + + // Validate destination + if (payment.destination !== tip.artist.walletAddress) { + return false; + } + + // Validate asset code + if (tip.assetCode && payment.asset_code !== tip.assetCode) { + return false; + } + + return true; + + } catch (error) { + this.logger.error('Error validating transaction details:', error); + return false; + } + } + + private async markTipVerified(tip: Tip, verificationResult: any): Promise { + tip.status = TipStatus.VERIFIED; + tip.verifiedAt = new Date(); + tip.verificationDetails = verificationResult.transactionDetails; + + await this.tipRepository.save(tip); + } + + private async handleVerificationFailure(tip: Tip, error: string): Promise { + tip.status = TipStatus.FAILED; + tip.failureReason = error; + tip.failedAt = new Date(); + + await this.tipRepository.save(tip); + + // Send failure notification to sender + await this.notificationsService.createNotification({ + userId: tip.senderId, + type: NotificationType.TIP_FAILED, + title: 'Tip Verification Failed', + message: `Your tip of ${tip.amount} ${tip.assetCode} could not be verified: ${error}`, + data: { + tipId: tip.id, + error, + }, + }); + } + + private async handleModerationBlock(tip: Tip, reason: string): Promise { + tip.status = TipStatus.BLOCKED; + tip.moderationReason = reason; + tip.moderatedAt = new Date(); + + await this.tipRepository.save(tip); + + // Send moderation notification to sender + await this.notificationsService.createNotification({ + userId: tip.senderId, + type: NotificationType.TIP_BLOCKED, + title: 'Tip Blocked', + message: `Your tip was blocked by moderation: ${reason}`, + data: { + tipId: tip.id, + reason, + }, + }); + } + + private async sendVerificationNotifications(tip: Tip): Promise { + // Notification to artist + await this.notificationsService.createNotification({ + userId: tip.artist.userId, + type: NotificationType.TIP_RECEIVED, + title: 'New Tip Received!', + message: `You received a tip of ${tip.amount} ${tip.assetCode}!`, + data: { + tipId: tip.id, + amount: tip.amount, + assetCode: tip.assetCode, + senderId: tip.senderId, + }, + }); + + // Confirmation to sender + await this.notificationsService.createNotification({ + userId: tip.senderId, + type: NotificationType.TIP_VERIFIED, + title: 'Tip Verified', + message: `Your tip of ${tip.amount} ${tip.assetCode} has been verified and delivered!`, + data: { + tipId: tip.id, + amount: tip.amount, + assetCode: tip.assetCode, + artistId: tip.artistId, + }, + }); + } + + private async scheduleRetry(job: Job, retryCount: number): Promise { + const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); // Exponential backoff, max 30s + + await job.add('tip-verification', { + ...job.data, + retryCount, + }, { + delay, + removeOnComplete: true, + removeOnFail: true, + }); + + this.logger.log(`Scheduled retry for tip ${job.data.tipId} in ${delay}ms`); + } +}