-
Notifications
You must be signed in to change notification settings - Fork 83
feat: Implement consolidations for issues #411-414 #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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', | ||
| }, | ||
| }; | ||
| } | ||
| } |
| 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 {} | ||
| 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; | ||
| } |
| 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; | ||
| } |
| 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 |
| 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); | ||
| } | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the actual current-user field name here.
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
Suggested change
🧰 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 |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+44
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @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); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import
ApiVersionModuleinto the application root.backend/src/app.module.ts:86-140still omits this module, so Nest never mountsApiVersionControllerand/api-versionstays unreachable.🤖 Prompt for AI Agents