diff --git a/harvest-finance/backend/src/database/entities/strategy.entity.ts b/harvest-finance/backend/src/database/entities/strategy.entity.ts index 3b74a334..5b9e9c3d 100644 --- a/harvest-finance/backend/src/database/entities/strategy.entity.ts +++ b/harvest-finance/backend/src/database/entities/strategy.entity.ts @@ -1,10 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; export enum CompoundingFrequency { DAILY = 'daily', @@ -19,14 +13,16 @@ export const COMPOUNDING_FREQUENCY_N: Record = { }; @Entity('strategies') +@Index('idx_strategies_compounding_frequency', ['compoundingFrequency']) export class Strategy { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ length: 100 }) + @Column({ type: 'varchar', length: 100 }) name: string; @Column({ + name: 'compounding_frequency', type: 'enum', enum: CompoundingFrequency, default: CompoundingFrequency.DAILY, diff --git a/harvest-finance/backend/src/database/entities/vault-apy-history.entity.ts b/harvest-finance/backend/src/database/entities/vault-apy-history.entity.ts index 084843d9..45084600 100644 --- a/harvest-finance/backend/src/database/entities/vault-apy-history.entity.ts +++ b/harvest-finance/backend/src/database/entities/vault-apy-history.entity.ts @@ -26,11 +26,10 @@ export class VaultApyHistory { @JoinColumn({ name: 'vault_id' }) vault: Vault; - @Column({ - type: 'decimal', - precision: 18, - scale: 8, - }) + @Column({ type: 'decimal', precision: 18, scale: 8, nullable: true }) + apr: number | null; + + @Column({ type: 'decimal', precision: 18, scale: 8 }) apy: number; @Column({ name: 'snapshot_date', type: 'date' }) diff --git a/harvest-finance/backend/src/database/migrations/1700000000017-CreateStrategyAndApyHistory.ts b/harvest-finance/backend/src/database/migrations/1700000000017-CreateStrategyAndApyHistory.ts index c3ed7379..2c775ab5 100644 --- a/harvest-finance/backend/src/database/migrations/1700000000017-CreateStrategyAndApyHistory.ts +++ b/harvest-finance/backend/src/database/migrations/1700000000017-CreateStrategyAndApyHistory.ts @@ -76,6 +76,13 @@ export class CreateStrategyAndApyHistory1700000000017 implements MigrationInterf type: 'uuid', isNullable: false, }, + { + name: 'apr', + type: 'decimal', + precision: 18, + scale: 8, + isNullable: true, + }, { name: 'apy', type: 'decimal', 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 4c310d62..41fdb4d9 100644 --- a/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts +++ b/harvest-finance/backend/src/vaults/dto/vault-response.dto.ts @@ -10,6 +10,13 @@ }) apy: number; + @ApiProperty({ + example: 'daily', + description: 'Compounding frequency used for APY calculation', + enum: ['daily', 'weekly', 'monthly'], + }) + compoundingFrequency: string; + @ApiProperty({ example: '2024-12-31T23:59:59Z', description: 'Vault maturity date', diff --git a/harvest-finance/backend/src/vaults/vaults.apy.spec.ts b/harvest-finance/backend/src/vaults/vaults.apy.spec.ts new file mode 100644 index 00000000..3b3eb410 --- /dev/null +++ b/harvest-finance/backend/src/vaults/vaults.apy.spec.ts @@ -0,0 +1,129 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { VaultsService } from './vaults.service'; +import { Vault, VaultStatus, VaultType } from '../database/entities/vault.entity'; +import { Deposit } from '../database/entities/deposit.entity'; +import { Withdrawal } from '../database/entities/withdrawal.entity'; +import { Strategy, CompoundingFrequency } from '../database/entities/strategy.entity'; +import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { CustomLoggerService } from '../logger/custom-logger.service'; +import { VaultGateway } from '../realtime/vault.gateway'; +import { ContractCacheService } from '../common/cache/contract-cache.service'; +import { InputSanitizerService } from '../common/sanitization/input-sanitizer.service'; +import { DepositEventService } from './deposit-event.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AuthService } from '../auth/auth.service'; + +describe('VaultsService APY behavior', () => { + let service: VaultsService; + const mockVaultRepository = { findOne: jest.fn(), update: jest.fn(), find: jest.fn(), save: jest.fn(), create: jest.fn() }; + const mockDepositRepository = { create: jest.fn(), findOne: jest.fn(), find: jest.fn(), update: jest.fn(), createQueryBuilder: jest.fn() }; + const mockWithdrawalRepository = { create: jest.fn(), findOne: jest.fn(), update: jest.fn() }; + const mockStrategyRepository = { findOne: jest.fn() }; + const mockApyHistoryRepository = { create: jest.fn(), save: jest.fn(), createQueryBuilder: jest.fn() }; + const mockDataSource = { transaction: jest.fn(), getRepository: jest.fn(), createQueryBuilder: jest.fn() }; + const mockNotificationsService = { create: jest.fn() }; + const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() }; + const mockVaultGateway = { emitDeposit: jest.fn(), emitWithdrawal: jest.fn() }; + const mockEventEmitter = { emit: jest.fn() }; + const mockContractCache = { getVaultState: jest.fn((_id: string, loader: () => Promise) => loader()) }; + const mockSanitizer = { validateUUID: jest.fn((id: string) => id) }; + const mockDepositEventService = { appendEvent: jest.fn(), getDepositHistory: jest.fn(), getUserDepositHistory: jest.fn(), getVaultDepositHistory: jest.fn(), mapEventToResponse: jest.fn() }; + const mockAuthService = { isEmailVerified: jest.fn().mockResolvedValue(true) }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultsService, + { provide: getRepositoryToken(Vault), useValue: mockVaultRepository }, + { provide: getRepositoryToken(Deposit), useValue: mockDepositRepository }, + { provide: getRepositoryToken(Withdrawal), useValue: mockWithdrawalRepository }, + { provide: getRepositoryToken(Strategy), useValue: mockStrategyRepository }, + { provide: getRepositoryToken(VaultApyHistory), useValue: mockApyHistoryRepository }, + { provide: DataSource, useValue: mockDataSource }, + { provide: NotificationsService, useValue: mockNotificationsService }, + { provide: CustomLoggerService, useValue: mockLogger }, + { provide: VaultGateway, useValue: mockVaultGateway }, + { provide: ContractCacheService, useValue: mockContractCache }, + { provide: InputSanitizerService, useValue: mockSanitizer }, + { provide: DepositEventService, useValue: mockDepositEventService }, + { provide: EventEmitter2, useValue: mockEventEmitter }, + { provide: AuthService, useValue: mockAuthService }, + ], + }).compile(); + + service = module.get(VaultsService); + }); + + it('calculates APY for daily, weekly, and monthly compounding', () => { + expect(service.calculateApy(5, CompoundingFrequency.DAILY)).toBeCloseTo(5.13, 2); + expect(service.calculateApy(5, CompoundingFrequency.WEEKLY)).toBeCloseTo(5.12, 2); + expect(service.calculateApy(5, CompoundingFrequency.MONTHLY)).toBeCloseTo(5.11, 2); + }); + + it('returns zero APY for zero APR and defaults invalid frequencies to daily', () => { + expect(service.calculateApy(0, CompoundingFrequency.DAILY)).toBe(0); + expect(service.calculateApy(5, 'invalid' as CompoundingFrequency)).toBeCloseTo(5.13, 2); + }); + + it('persists a daily snapshot with APR and APY for a vault', async () => { + const insertBuilder = { + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orIgnore: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + mockDataSource.createQueryBuilder.mockReturnValue(insertBuilder); + mockVaultRepository.findOne.mockResolvedValue({ + id: 'vault-1', + interestRate: 5, + strategy: { compoundingFrequency: CompoundingFrequency.MONTHLY }, + }); + + await service.recordApySnapshot('vault-1'); + + expect(insertBuilder.values).toHaveBeenCalledWith(expect.objectContaining({ + vault_id: 'vault-1', + apr: 5, + apy: expect.any(Number), + snapshot_date: expect.any(Date), + })); + }); + + it('includes compounding frequency in the vault response payload', () => { + const vault = { + id: 'vault-1', + ownerId: 'user-1', + type: VaultType.CROP_PRODUCTION, + status: VaultStatus.ACTIVE, + vaultName: 'Test Vault', + description: 'Test vault description', + symbol: 'TEST', + assetPair: 'XLM/USDC', + totalDeposits: 1000, + maxCapacity: 10000, + interestRate: 5, + maturityDate: null, + lockPeriodEnd: null, + isPublic: true, + requiresMultiSignature: false, + approvalThreshold: 1, + currentApprovals: 0, + strategy: { compoundingFrequency: CompoundingFrequency.WEEKLY }, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + availableCapacity: 9000, + utilizationPercentage: 10, + approvalStatus: 'NOT_REQUIRED', + } as Vault; + + const response = service.mapVaultToResponse(vault); + + expect(response.apr).toBe(5); + expect(response.apy).toBeCloseTo(5.12, 2); + expect(response.compoundingFrequency).toBe(CompoundingFrequency.WEEKLY); + }); +}); diff --git a/harvest-finance/backend/src/vaults/vaults.service.ts b/harvest-finance/backend/src/vaults/vaults.service.ts index 3e434ecb..52dc28d0 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.ts @@ -9,7 +9,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; 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 { DepositEvent, DepositEventType } from '../database/entities/deposit-event.entity'; import { ExternalPaymentEventType } from './dto/external-payment-notification.dto'; import { @@ -19,6 +18,8 @@ import { import { Strategy, CompoundingFrequency, COMPOUNDING_FREQUENCY_N } from '../database/entities/strategy.entity'; import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AuthService } from '../auth/auth.service'; import { VaultReservation } from './entities/vault-reservation.entity'; import { CreateReservationDto } from './dto/create-reservation.dto'; @@ -65,8 +66,6 @@ export class VaultsService { @InjectRepository(VaultReservation) private reservationRepository: Repository, - @InjectRepository(VaultApyHistory) - private vaultApyHistoryRepository: Repository, private dataSource: DataSource, private notificationsService: NotificationsService, @@ -90,23 +89,36 @@ export class VaultsService { */ calculateApy( apr: number, - frequency: CompoundingFrequency = CompoundingFrequency.DAILY, + frequency: CompoundingFrequency | string | null | undefined = CompoundingFrequency.DAILY, ): number { if (apr === 0) return 0; - const n = COMPOUNDING_FREQUENCY_N[frequency]; + const normalizedFrequency = this.normalizeCompoundingFrequency(frequency); + const n = COMPOUNDING_FREQUENCY_N[normalizedFrequency]; const decimalApr = apr / 100; const apy = Math.pow(1 + decimalApr / n, n) - 1; - return Math.round(apy * 10000) / 100; // Return as percentage, rounded to 2 decimal places + return Number((apy * 100).toFixed(2)); } /** * Get the effective compounding frequency for a vault. - * Falls back to DAILY if no strategy is assigned. + * Falls back to DAILY if no strategy is assigned or the stored value is invalid. */ private getVaultCompoundingFrequency(vault: Vault): CompoundingFrequency { - return vault.strategy?.compoundingFrequency ?? CompoundingFrequency.DAILY; + return this.normalizeCompoundingFrequency(vault.strategy?.compoundingFrequency); + } + + private normalizeCompoundingFrequency( + frequency: CompoundingFrequency | string | null | undefined, + ): CompoundingFrequency { + if (frequency === CompoundingFrequency.WEEKLY) { + return CompoundingFrequency.WEEKLY; + } + if (frequency === CompoundingFrequency.MONTHLY) { + return CompoundingFrequency.MONTHLY; + } + return CompoundingFrequency.DAILY; } async getVaultById(vaultId: string): Promise { @@ -687,13 +699,9 @@ export class VaultsService { mapVaultToResponse(vault: Vault): VaultResponseDto { const apr = Number(vault.interestRate); - - const apy = this.calculateApy(apr, this.getVaultCompoundingFrequency(vault)); - - const compoundingFrequency = vault.compoundingFrequency || 'daily'; + const compoundingFrequency = this.getVaultCompoundingFrequency(vault); const apy = this.calculateApy(apr, compoundingFrequency); - return { id: vault.id, ownerId: vault.ownerId, @@ -707,12 +715,10 @@ export class VaultsService { maxCapacity: Number(vault.maxCapacity), availableCapacity: vault.availableCapacity, utilizationPercentage: vault.utilizationPercentage, - interestRate: apr, + interestRate: apr, apr, apy, - compoundingFrequency, - maturityDate: vault.maturityDate, lockPeriodEnd: vault.lockPeriodEnd, isPublic: vault.isPublic, @@ -752,10 +758,11 @@ export class VaultsService { .into('vault_apy_history') .values({ vault_id: vault.id, + apr, apy, snapshot_date: snapshotDate, }) - .orIgnore() // Ignore if a snapshot for this vault/date already exists + .orIgnore() .execute(); } @@ -914,7 +921,6 @@ export class VaultsService { vaultId?: string, timeRange: string = '30d', ): Promise { - // Calculate date range const now = new Date(); let daysBack = 30; @@ -934,7 +940,6 @@ export class VaultsService { const startDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000); - const query = this.apyHistoryRepository .createQueryBuilder('history') .where('history.snapshotDate >= :startDate', { @@ -942,47 +947,30 @@ export class VaultsService { }) .orderBy('history.snapshotDate', 'ASC'); - if (vaultId) { - query.andWhere('history.vaultId = :vaultId', { vaultId }); - - const queryBuilder = this.vaultApyHistoryRepository - .createQueryBuilder('history') - .where('history.snapshotDate >= :startDate', { - startDate: startDate.toISOString().split('T')[0], - }) - .orderBy('history.snapshotDate', 'ASC'); - if (vaultId) { query.andWhere('history.vaultId = :vaultId', { vaultId }); } const rows = await query.getMany(); - if (records.length > 0) { - return records.map(r => ({ - date: typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0], - apy: Number(r.apy), - vaultId: r.vaultId, - })); - } - - // Fallback: If no real data exists, generate some mock data so charts aren't blank - const dataPoints: { date: string; apy: number; vaultId: string }[] = []; - for (let i = 0; i < daysBack; i++) { - const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000); - const baseApy = 8 + Math.sin(i / 10) * 2 + Math.random() * 1; - const apy = Math.max(0, Math.min(15, baseApy)); - - dataPoints.push({ - date: date.toISOString().split('T')[0], - apy: Math.round(apy * 100) / 100, - vaultId: vaultId || 'all', - }); + if (rows.length === 0) { + // Fallback: If no real data exists, generate some mock data so charts aren't blank + const dataPoints: { date: string; apy: number; vaultId: string }[] = []; + for (let i = 0; i < daysBack; i++) { + const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000); + const baseApy = 8 + Math.sin(i / 10) * 2 + Math.random() * 1; + const apy = Math.max(0, Math.min(15, baseApy)); + + dataPoints.push({ + date: date.toISOString().split('T')[0], + apy: Math.round(apy * 100) / 100, + vaultId: vaultId || 'all', + }); + } + return dataPoints; } - const rows = await query.getMany(); - return rows.map((row) => ({ date: row.snapshotDate.toISOString().split('T')[0], apy: Number(row.apy),