diff --git a/harvest-finance/backend/src/app.module.ts b/harvest-finance/backend/src/app.module.ts index 9e02da07..31191d28 100644 --- a/harvest-finance/backend/src/app.module.ts +++ b/harvest-finance/backend/src/app.module.ts @@ -93,6 +93,7 @@ import { VaultReservation } from './vaults/entities/vault-reservation.entity'; import { Session } from './database/entities/session.entity'; import { SecurityEvent } from './database/entities/security-event.entity'; import { CreateVaultApyHistory1700000000017 } from './database/migrations/1700000000017-CreateVaultApyHistory'; +import { AddVaultFees1700000000019 } from './database/migrations/1700000000019-AddVaultFees'; import { AddRefreshTokenRotation1700000000022 } from './database/migrations/1700000000022-AddRefreshTokenRotation'; import { DomainEventsModule } from './domain-events'; import { DomainEventHandlersModule } from './common/events'; @@ -163,7 +164,9 @@ import { CreateCustodialWallets1700000000021 } from './database/migrations/17000 CreateVaultReservations1700000000018, AddDepositorConcentrationThreshold1700000000022, CreateVaultApyHistory1700000000017, + AddVaultFees1700000000019, CreateCustodialWallets1700000000021, + AddRefreshTokenRotation1700000000022, ], synchronize: false, migrationsRun: false, diff --git a/harvest-finance/backend/src/database/entities/deposit-event.entity.ts b/harvest-finance/backend/src/database/entities/deposit-event.entity.ts index bf96fc79..58b3e745 100644 --- a/harvest-finance/backend/src/database/entities/deposit-event.entity.ts +++ b/harvest-finance/backend/src/database/entities/deposit-event.entity.ts @@ -11,6 +11,7 @@ export enum DepositEventType { CONFIRMED = 'CONFIRMED', FAILED = 'FAILED', REFUNDED = 'REFUNDED', + FEE_COLLECTED = 'FEE_COLLECTED', } /** diff --git a/harvest-finance/backend/src/database/entities/vault.entity.ts b/harvest-finance/backend/src/database/entities/vault.entity.ts index 164a1eaf..f03f8219 100644 --- a/harvest-finance/backend/src/database/entities/vault.entity.ts +++ b/harvest-finance/backend/src/database/entities/vault.entity.ts @@ -157,6 +157,19 @@ export class Vault { @Column({ name: 'stellar_account_address', length: 56, nullable: true, default: null }) stellarAccountAddress: string | null; + @Column({ name: 'entry_fee_bps', type: 'int', default: 0 }) + entryFeeBps: number; + + @Column({ name: 'exit_fee_bps', type: 'int', default: 0 }) + exitFeeBps: number; + + @Column({ name: 'performance_fee_bps', type: 'int', default: 0 }) + performanceFeeBps: number; + + /** Wallet address where collected fees are sent */ + @Column({ name: 'fee_address', type: 'text', nullable: true }) + feeAddress: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/harvest-finance/backend/src/database/migrations/1700000000019-AddVaultFees.ts b/harvest-finance/backend/src/database/migrations/1700000000019-AddVaultFees.ts new file mode 100644 index 00000000..765b5ebd --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000019-AddVaultFees.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVaultFees1700000000019 implements MigrationInterface { + name = 'AddVaultFees1700000000019'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "vaults" ADD COLUMN IF NOT EXISTS "entry_fee_bps" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "vaults" ADD COLUMN IF NOT EXISTS "exit_fee_bps" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "vaults" ADD COLUMN IF NOT EXISTS "performance_fee_bps" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "vaults" ADD COLUMN IF NOT EXISTS "fee_address" text`); + + // Extend deposit_events event type enum to support fee collection entries + await queryRunner.query(`ALTER TYPE "deposit_events_event_type_enum" ADD VALUE IF NOT EXISTS 'FEE_COLLECTED'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "vaults" DROP COLUMN IF EXISTS "entry_fee_bps"`); + await queryRunner.query(`ALTER TABLE "vaults" DROP COLUMN IF EXISTS "exit_fee_bps"`); + await queryRunner.query(`ALTER TABLE "vaults" DROP COLUMN IF EXISTS "performance_fee_bps"`); + await queryRunner.query(`ALTER TABLE "vaults" DROP COLUMN IF EXISTS "fee_address"`); + // Note: removing an enum value requires recreating the type; left intentionally for safety + } +} diff --git a/harvest-finance/backend/src/vaults/dto/update-vault-fees.dto.ts b/harvest-finance/backend/src/vaults/dto/update-vault-fees.dto.ts new file mode 100644 index 00000000..a791dc09 --- /dev/null +++ b/harvest-finance/backend/src/vaults/dto/update-vault-fees.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class UpdateVaultFeesDto { + @ApiProperty({ example: 50, description: 'Entry fee in basis points (50 bps = 0.5%)' }) + @IsInt() + @Min(0) + entryFeeBps: number; + + @ApiProperty({ example: 50, description: 'Exit fee in basis points (50 bps = 0.5%)' }) + @IsInt() + @Min(0) + exitFeeBps: number; + + @ApiProperty({ example: 1000, description: 'Performance fee in basis points (1000 bps = 10%)' }) + @IsInt() + @Min(0) + performanceFeeBps: number; + + @ApiProperty({ + example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + description: 'Wallet address where fees are transferred', + required: false, + }) + @IsOptional() + @IsString() + feeAddress?: string; +} diff --git a/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts b/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts index 60053643..6c27f014 100644 --- a/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts +++ b/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts @@ -162,6 +162,18 @@ export class VaultResponseDto { description: 'Last update date', }) updatedAt: Date; + + @ApiProperty({ example: 50, description: 'Entry fee in basis points' }) + entryFeeBps: number; + + @ApiProperty({ example: 50, description: 'Exit fee in basis points' }) + exitFeeBps: number; + + @ApiProperty({ example: 1000, description: 'Performance fee in basis points' }) + performanceFeeBps: number; + + @ApiProperty({ example: 'GXXX...', description: 'Fee recipient address', required: false, nullable: true }) + feeAddress: string | null; } export class DepositResponseDto { @@ -235,6 +247,12 @@ export class DepositVaultResponseDto { description: "User's total deposits across all vaults", }) userTotalDeposits: number; + + @ApiProperty({ example: 5.0, description: 'Fee amount deducted' }) + feeAmount: number; + + @ApiProperty({ example: 995.0, description: 'Net amount credited after fee deduction' }) + netAmount: number; } export class BatchDepositResponseDto { diff --git a/harvest-finance/backend/src/vaults/fees.service.ts b/harvest-finance/backend/src/vaults/fees.service.ts new file mode 100644 index 00000000..9f32c4b9 --- /dev/null +++ b/harvest-finance/backend/src/vaults/fees.service.ts @@ -0,0 +1,56 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +// Platform-wide maximums (configurable via env; hard-coded fallbacks) +const MAX_ENTRY_FEE_BPS = parseInt(process.env.MAX_ENTRY_FEE_BPS ?? '500', 10); // 5% +const MAX_EXIT_FEE_BPS = parseInt(process.env.MAX_EXIT_FEE_BPS ?? '500', 10); // 5% +const MAX_PERFORMANCE_FEE_BPS = parseInt(process.env.MAX_PERFORMANCE_FEE_BPS ?? '3000', 10); // 30% + +export interface FeeBreakdown { + grossAmount: number; + feeAmount: number; + netAmount: number; + feeBps: number; +} + +@Injectable() +export class FeesService { + validateFees(entryFeeBps: number, exitFeeBps: number, performanceFeeBps: number): void { + if (entryFeeBps < 0 || entryFeeBps > MAX_ENTRY_FEE_BPS) { + throw new BadRequestException( + `Entry fee must be between 0 and ${MAX_ENTRY_FEE_BPS} bps (${MAX_ENTRY_FEE_BPS / 100}%)`, + ); + } + if (exitFeeBps < 0 || exitFeeBps > MAX_EXIT_FEE_BPS) { + throw new BadRequestException( + `Exit fee must be between 0 and ${MAX_EXIT_FEE_BPS} bps (${MAX_EXIT_FEE_BPS / 100}%)`, + ); + } + if (performanceFeeBps < 0 || performanceFeeBps > MAX_PERFORMANCE_FEE_BPS) { + throw new BadRequestException( + `Performance fee must be between 0 and ${MAX_PERFORMANCE_FEE_BPS} bps (${MAX_PERFORMANCE_FEE_BPS / 100}%)`, + ); + } + } + + calculateFee(grossAmount: number, feeBps: number): FeeBreakdown { + const feeAmount = Math.round(grossAmount * feeBps) / 10000; + return { + grossAmount, + feeAmount, + netAmount: grossAmount - feeAmount, + feeBps, + }; + } + + get platformMaxEntryFeeBps(): number { + return MAX_ENTRY_FEE_BPS; + } + + get platformMaxExitFeeBps(): number { + return MAX_EXIT_FEE_BPS; + } + + get platformMaxPerformanceFeeBps(): number { + return MAX_PERFORMANCE_FEE_BPS; + } +} diff --git a/harvest-finance/backend/src/vaults/vaults.controller.ts b/harvest-finance/backend/src/vaults/vaults.controller.ts index 1077041e..f6f03cdb 100644 --- a/harvest-finance/backend/src/vaults/vaults.controller.ts +++ b/harvest-finance/backend/src/vaults/vaults.controller.ts @@ -3,6 +3,7 @@ import { Post, Get, Delete, + Patch, Param, Body, Query, @@ -31,6 +32,7 @@ import { BatchDepositDto } from './dto/batch-deposit.dto'; import { CloneVaultDto } from './dto/clone-vault.dto'; import { CreateReservationDto } from './dto/create-reservation.dto'; import { ReservationResponseDto } from './dto/reservation-response.dto'; +import { UpdateVaultFeesDto } from './dto/update-vault-fees.dto'; import { BatchDepositResponseDto, DepositVaultResponseDto, @@ -393,9 +395,26 @@ export class VaultsController { ); } - @Post(':vaultId/request-approval') + @Patch(':vaultId/fees') @Throttle({ default: { limit: 10, ttl: 60000 } }) @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Configure entry, exit, and performance fees for a vault' }) + @ApiParam({ name: 'vaultId', description: 'Vault ID (UUID)' }) + @ApiBody({ type: UpdateVaultFeesDto }) + @ApiResponse({ status: 200, description: 'Fee configuration updated', type: VaultResponseDto }) + @ApiResponse({ status: 400, description: 'Fee values exceed platform maximums' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Only vault owner can configure fees' }) + @ApiResponse({ status: 404, description: 'Vault not found' }) + async updateVaultFees( + @Param('vaultId') vaultId: string, + @Body() dto: UpdateVaultFeesDto, + @Request() req: any, + ): Promise { + return this.vaultsService.updateVaultFees(vaultId, req.user.id, dto); + } + + @Post(':vaultId/request-approval') @Throttle({ default: { limit: 10, ttl: 60000 } }) + @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Request approval from another user for vault operations' }) @ApiParam({ name: 'vaultId', diff --git a/harvest-finance/backend/src/vaults/vaults.integration.spec.ts b/harvest-finance/backend/src/vaults/vaults.integration.spec.ts index 48dd3e05..687e3cc5 100644 --- a/harvest-finance/backend/src/vaults/vaults.integration.spec.ts +++ b/harvest-finance/backend/src/vaults/vaults.integration.spec.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { VaultsService } from './vaults.service'; +import { FeesService } from './fees.service'; import { WithdrawalQueueService } from './withdrawal-queue.service'; import { ExternalPaymentEventType } from './dto/external-payment-notification.dto'; import { PaymentReceivedEvent, DepositCompletedEvent, WithdrawalConfirmedEvent, DomainEventNames } from '../domain-events'; @@ -210,6 +211,11 @@ describe('VaultsService — Yield Strategy Integration', () => { { provide: DepositEventService, useValue: mockDepositEventService }, { provide: WithdrawalQueueService, useValue: { processQueue: jest.fn().mockResolvedValue(undefined) } }, { provide: EventEmitter2, useValue: mockEventEmitter }, + FeesService, + { + provide: WithdrawalQueueService, + useValue: { processWithdrawalQueue: jest.fn().mockResolvedValue(undefined), enqueueWithdrawal: jest.fn().mockResolvedValue(undefined) }, + }, ], }).compile(); diff --git a/harvest-finance/backend/src/vaults/vaults.module.ts b/harvest-finance/backend/src/vaults/vaults.module.ts index 795b733d..14b4e7b4 100644 --- a/harvest-finance/backend/src/vaults/vaults.module.ts +++ b/harvest-finance/backend/src/vaults/vaults.module.ts @@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VaultsController } from './vaults.controller'; import { VaultsService } from './vaults.service'; +import { FeesService } from './fees.service'; import { CommandHandlers } from './cqrs/commands/handlers'; import { QueryHandlers } from './cqrs/queries/handlers'; import { EventHandlers } from './cqrs/events/handlers'; @@ -16,6 +17,7 @@ import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; import { InsuranceClaim } from '../database/entities/insurance-claim.entity'; import { DepositEventService } from './deposit-event.service'; import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; import { StellarModule } from '../stellar/stellar.module'; import { VaultAccountMonitorService } from './vault-account-monitor.service'; import { InsuranceFundService } from './insurance-fund.service'; @@ -24,28 +26,12 @@ import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { CommonModule } from '../common/common.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Module } from '@nestjs/common'; -import { AuthModule } from '../auth/auth.module'; -import { NotificationsModule } from '../notifications/notifications.module'; -import { RealtimeModule } from '../realtime/realtime.module'; -import { CommonModule } from '../common/common.module'; -import { VaultsController } from './vaults.controller'; -import { VaultsService } from './vaults.service'; -import { DepositEventService } from './deposit-event.service'; -import { Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory, InsuranceClaim } from './entities'; // Adjust entity path if necessary - -// Keep both sets of imported files to prevent regressions -import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; -import { StellarModule } from '../stellar/stellar.module'; -import { VaultAccountMonitorService } from './vault-account-monitor.service'; -import { WithdrawalQueueService } from './withdrawal-queue.service'; -import { InsuranceFundController } from './insurance-fund.controller'; -import { InsuranceFundService } from './insurance-fund.service'; +import { AnalyticsModule } from '../analytics/analytics.module'; @Module({ imports: [ TypeOrmModule.forFeature([Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory, InsuranceClaim]), + CqrsModule, AuthModule, NotificationsModule, RealtimeModule, @@ -53,25 +39,20 @@ import { InsuranceFundService } from './insurance-fund.service'; StellarModule, AnalyticsModule, ], - controllers: [ - VaultsController, - InsuranceFundController - ], + controllers: [VaultsController, InsuranceFundController], providers: [ - VaultsService, - DepositEventService, - WithdrawalConfirmedHandler, - VaultAccountMonitorService, - WithdrawalQueueService, - InsuranceFundService + VaultsService, + FeesService, + DepositEventService, + WithdrawalConfirmedHandler, + WithdrawalQueueService, + VaultAccountMonitorService, + InsuranceFundService, + VaultReadRepository, + ...CommandHandlers, + ...QueryHandlers, + ...EventHandlers, ], - exports: [ - VaultsService, - DepositEventService, - WithdrawalQueueService, - InsuranceFundService - ], -}) -export class VaultsModule {} + exports: [VaultsService, DepositEventService, WithdrawalQueueService, InsuranceFundService], }) export class VaultsModule {} \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/vaults.service.spec.ts b/harvest-finance/backend/src/vaults/vaults.service.spec.ts index 94d596e6..b3dd163b 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.spec.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.spec.ts @@ -7,6 +7,8 @@ import { UnauthorizedException, } from '@nestjs/common'; import { VaultsService } from './vaults.service'; +import { FeesService } from './fees.service'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; import { Vault, VaultStatus, VaultType } from '../database/entities/vault.entity'; import { Deposit, DepositStatus } from '../database/entities/deposit.entity'; import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; @@ -193,7 +195,11 @@ describe('VaultsService', () => { { provide: ContractCacheService, useValue: mockContractCache }, { provide: InputSanitizerService, useValue: mockSanitizer }, { provide: DepositEventService, useValue: mockDepositEventService }, - { provide: WithdrawalQueueService, useValue: {} }, + FeesService, + { + provide: WithdrawalQueueService, + useValue: { processWithdrawalQueue: jest.fn().mockResolvedValue(undefined), enqueueWithdrawal: jest.fn().mockResolvedValue(undefined) }, + }, ], }).compile(); diff --git a/harvest-finance/backend/src/vaults/vaults.service.ts b/harvest-finance/backend/src/vaults/vaults.service.ts index 8ef50046..e41e67d7 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.ts @@ -10,7 +10,7 @@ import { Cron } from '@nestjs/schedule'; import { Vault, VaultStatus } from '../database/entities/vault.entity'; import { Deposit, DepositStatus } from '../database/entities/deposit.entity'; import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; -import { DepositEventType } from '../database/entities/deposit-event.entity'; +import { DepositEvent, DepositEventType } from '../database/entities/deposit-event.entity'; import { ExternalPaymentEventType } from './dto/external-payment-notification.dto'; import { Withdrawal, @@ -38,16 +38,9 @@ import { InputSanitizerService } from '../common/sanitization/input-sanitizer.se import { VaultApproval } from '../database/entities/vault-approval.entity'; import { User } from '../database/entities/user.entity'; import { DepositEventService } from './deposit-event.service'; +import { FeesService } from './fees.service'; +import { UpdateVaultFeesDto } from './dto/update-vault-fees.dto'; import { WithdrawalQueueService } from './withdrawal-queue.service'; -import { DepositEventResponseDto } from './dto/deposit-event-response.dto'; -import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { - DepositCompletedEvent, - DomainEventNames, - WithdrawalConfirmedEvent, - WithdrawalCompletedEvent, - PaymentReceivedEvent, -} from '../domain-events'; const MAX_SAFE_DEPOSIT = 1e30; const LARGE_DEPOSIT_THRESHOLD = 10000; @@ -72,6 +65,7 @@ export class VaultsService { private contractCache: ContractCacheService, private sanitizer: InputSanitizerService, private depositEventService: DepositEventService, + private readonly feesService: FeesService, private readonly eventEmitter: EventEmitter2, private readonly withdrawalQueueService: WithdrawalQueueService, ) {} @@ -118,6 +112,8 @@ export class VaultsService { : null, deposit: this.mapDepositToResponse(existingDeposit), userTotalDeposits, + feeAmount: 0, + netAmount: Number(existingDeposit.amount), }; } } @@ -184,6 +180,8 @@ export class VaultsService { idempotencyKey: idempotencyKey || null, }); + const entryFee = this.feesService.calculateFee(amount, vault.entryFeeBps ?? 0); + const result = await this.dataSource.transaction(async (manager) => { const savedDeposit = await manager.save(deposit); @@ -200,7 +198,29 @@ export class VaultsService { manager, ); - await manager.increment(Vault, { id: vaultId }, 'totalDeposits', amount); + // Log fee collection event when an entry fee is charged + if (entryFee.feeAmount > 0) { + await this.depositEventService.appendEvent( + { + depositId: savedDeposit.id, + userId, + vaultId, + eventType: DepositEventType.FEE_COLLECTED, + amount: entryFee.feeAmount, + payload: { + feeType: 'entry', + feeBps: entryFee.feeBps, + grossAmount: entryFee.grossAmount, + netAmount: entryFee.netAmount, + feeAddress: vault.feeAddress ?? null, + }, + }, + manager, + ); + } + + // Only the net amount (after entry fee) counts toward vault deposits + await manager.increment(Vault, { id: vaultId }, 'totalDeposits', entryFee.netAmount); const updatedVault = await manager.findOne(Vault, { where: { id: vaultId }, @@ -251,6 +271,8 @@ export class VaultsService { vault: result.vault ? this.mapVaultToResponse(result.vault) : null, deposit: this.mapDepositToResponse(result.deposit), userTotalDeposits, + feeAmount: entryFee.feeAmount, + netAmount: entryFee.netAmount, }; } @@ -429,12 +451,16 @@ export class VaultsService { .andWhere('deposit.status = :status', { status: DepositStatus.CONFIRMED }) .getRawOne(); + const batchEntryFee = this.feesService.calculateFee(item.amount, vaultById.get(item.vaultId)?.entryFeeBps ?? 0); + perDepositResponses.push({ vault: updatedVault ? this.mapVaultToResponse(updatedVault) : null, deposit: this.mapDepositToResponse(confirmedDeposit), userTotalDeposits: userTotalDeposits?.total ? parseFloat(userTotalDeposits.total) : 0, + feeAmount: batchEntryFee.feeAmount, + netAmount: batchEntryFee.netAmount, }); } @@ -936,6 +962,10 @@ export class VaultsService { approvalStatus: vault.approvalStatus, createdAt: vault.createdAt, updatedAt: vault.updatedAt, + entryFeeBps: vault.entryFeeBps ?? 0, + exitFeeBps: vault.exitFeeBps ?? 0, + performanceFeeBps: vault.performanceFeeBps ?? 0, + feeAddress: vault.feeAddress ?? null, }; } @@ -943,7 +973,7 @@ export class VaultsService { vaultId: string, userId: string, amount: number, - ): Promise<{ withdrawal: Withdrawal; vault: VaultResponseDto }> { + ): Promise<{ withdrawal: Withdrawal; vault: VaultResponseDto; feeAmount: number; netAmount: number }> { if (amount <= 0) { throw new BadRequestException('Withdrawal amount must be greater than 0'); } @@ -963,6 +993,8 @@ export class VaultsService { // Check if vault has sufficient liquidity for immediate withdrawal if (Number(vault.totalDeposits) >= amount) { + const exitFee = this.feesService.calculateFee(amount, vault.exitFeeBps ?? 0); + // Process withdrawal immediately const withdrawal = this.withdrawalRepository.create({ userId, @@ -974,6 +1006,27 @@ export class VaultsService { const result = await this.dataSource.transaction(async (manager) => { const savedWithdrawal = await manager.save(withdrawal); + // Log exit fee collection in the deposit_events audit log + if (exitFee.feeAmount > 0) { + // We reuse the deposit event log for fee audit entries (vault-scoped) + await manager.getRepository(DepositEvent).save( + manager.getRepository(DepositEvent).create({ + depositId: savedWithdrawal.id, + userId, + vaultId, + eventType: DepositEventType.FEE_COLLECTED, + amount: exitFee.feeAmount, + payload: { + feeType: 'exit', + feeBps: exitFee.feeBps, + grossAmount: exitFee.grossAmount, + netAmount: exitFee.netAmount, + feeAddress: vault.feeAddress ?? null, + }, + }), + ); + } + await manager.decrement(Vault, { id: vaultId }, 'totalDeposits', amount); const updatedVault = await manager.findOne(Vault, { @@ -1010,14 +1063,43 @@ export class VaultsService { userId, title: 'Withdrawal Confirmed', message: `Your withdrawal of ${amount} from vault ${vault.vaultName} has been confirmed.`, - type: NotificationType.WITHDRAWAL, // Fixed: should be WITHDRAWAL, not DEPOSIT + type: NotificationType.WITHDRAWAL, }); + return { + withdrawal: result.withdrawal, + vault: result.vault + ? this.mapVaultToResponse(result.vault) + : this.mapVaultToResponse(vault), + feeAmount: exitFee.feeAmount, + netAmount: exitFee.netAmount, + }; + } + + // Insufficient liquidity: queue the withdrawal for later processing + const queuedWithdrawal = this.withdrawalRepository.create({ + userId, + vaultId, + amount, + status: WithdrawalStatus.PENDING, + }); + + const savedQueuedWithdrawal = await this.withdrawalRepository.save(queuedWithdrawal); + await this.withdrawalQueueService.enqueueWithdrawal(savedQueuedWithdrawal.id); + + const queuedWithdrawalResult = await this.withdrawalRepository.findOne({ + where: { id: savedQueuedWithdrawal.id }, + }); + + if (!queuedWithdrawalResult) { + throw new NotFoundException('Withdrawal not found after queuing'); + } + return { - withdrawal: result.withdrawal, - vault: result.vault - ? this.mapVaultToResponse(result.vault) - : this.mapVaultToResponse(vault), + withdrawal: queuedWithdrawalResult, + vault: this.mapVaultToResponse(vault), + feeAmount: 0, + netAmount: amount, }; } @@ -1153,6 +1235,26 @@ export class VaultsService { return this.mapVaultToResponse(updatedVault); } + async updateVaultFees(vaultId: string, userId: string, dto: UpdateVaultFeesDto): Promise { + const vault = await this.getVaultById(vaultId); + + if (vault.ownerId !== userId) { + throw new UnauthorizedException('Only the vault owner can configure fees'); + } + + this.feesService.validateFees(dto.entryFeeBps, dto.exitFeeBps, dto.performanceFeeBps); + + await this.vaultRepository.update(vaultId, { + entryFeeBps: dto.entryFeeBps, + exitFeeBps: dto.exitFeeBps, + performanceFeeBps: dto.performanceFeeBps, + feeAddress: dto.feeAddress ?? vault.feeAddress, + }); + + const updated = await this.getVaultById(vaultId); + return this.mapVaultToResponse(updated); + } + async requestVaultApproval( vaultId: string, userId: string,