diff --git a/backend/package.json b/backend/package.json index cd8d55b7..d9133cc9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 29ebb218..b86e5599 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) @@ -1017,6 +1020,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -1359,6 +1368,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -2108,6 +2120,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -3322,6 +3338,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -5695,6 +5715,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + dependencies: + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -6052,6 +6078,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.19.11 + '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} '@types/mjml-core@4.15.2': @@ -6968,6 +6996,11 @@ snapshots: create-require@1.1.1: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -8467,6 +8500,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/backend/src/modules/user/dto/sweep-settings.dto.ts b/backend/src/modules/user/dto/sweep-settings.dto.ts new file mode 100644 index 00000000..1f40cab5 --- /dev/null +++ b/backend/src/modules/user/dto/sweep-settings.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SweepSettingsDto { + @ApiProperty({ + description: 'Whether automatic account sweeping is enabled', + example: true, + }) + autoSweepEnabled: boolean; + + @ApiProperty({ + description: 'Minimum balance threshold in XLM before sweeping excess funds', + example: 100.0, + nullable: true, + }) + sweepThreshold: number | null; + + @ApiProperty({ + description: 'Default savings product ID to sweep funds into', + example: '123e4567-e89b-12d3-a456-426614174000', + nullable: true, + }) + defaultSavingsProductId: string | null; +} diff --git a/backend/src/modules/user/dto/update-sweep-settings.dto.ts b/backend/src/modules/user/dto/update-sweep-settings.dto.ts new file mode 100644 index 00000000..839563de --- /dev/null +++ b/backend/src/modules/user/dto/update-sweep-settings.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; + +export class UpdateSweepSettingsDto { + @ApiProperty({ + description: 'Enable or disable automatic account sweeping', + example: true, + }) + @IsBoolean() + @IsOptional() + autoSweepEnabled?: boolean; + + @ApiProperty({ + description: 'Minimum balance threshold in XLM before sweeping excess funds', + example: 100.0, + minimum: 0, + }) + @IsNumber() + @Min(0) + @IsOptional() + sweepThreshold?: number; + + @ApiProperty({ + description: 'Default savings product ID to sweep funds into', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsUUID() + @IsOptional() + defaultSavingsProductId?: string; +} diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 788fe7c1..72c90aa7 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -41,6 +41,15 @@ export class User { @Column({ type: 'text', nullable: true }) kycRejectionReason: string; + @Column({ type: 'boolean', default: false }) + autoSweepEnabled: boolean; + + @Column({ type: 'decimal', precision: 14, scale: 2, nullable: true }) + sweepThreshold: number; + + @Column({ type: 'uuid', nullable: true }) + defaultSavingsProductId: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/modules/user/sweep-tasks.service.ts b/backend/src/modules/user/sweep-tasks.service.ts new file mode 100644 index 00000000..f3879f0f --- /dev/null +++ b/backend/src/modules/user/sweep-tasks.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity'; +import { StellarService } from '../blockchain/stellar.service'; + +@Injectable() +export class SweepTasksService { + private readonly logger = new Logger(SweepTasksService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly stellarService: StellarService, + ) {} + + /** + * Cron job that runs every hour to check and execute account sweeps + * Schedule: Every hour at minute 0 + */ + @Cron(CronExpression.EVERY_HOUR) + async handleAccountSweep() { + this.logger.log('Starting account sweep job...'); + + try { + // Fetch users with auto-sweep enabled + const usersWithAutoSweep = await this.getUsersWithAutoSweepEnabled(); + + this.logger.log(`Found ${usersWithAutoSweep.length} users with auto-sweep enabled`); + + for (const user of usersWithAutoSweep) { + await this.processSweepForUser(user); + } + + this.logger.log('Account sweep job completed successfully'); + } catch (error) { + this.logger.error('Error during account sweep job', error); + } + } + + /** + * Fetch all users who have auto-sweep enabled and have configured threshold + */ + private async getUsersWithAutoSweepEnabled(): Promise { + return this.userRepository.find({ + where: { + autoSweepEnabled: true, + }, + }); + } + + /** + * Process sweep for a single user + */ + private async processSweepForUser(user: User): Promise { + try { + this.logger.log(`Processing sweep for user ${user.id} (${user.email})`); + + // Validate user has required configuration + if (!user.publicKey) { + this.logger.warn(`User ${user.id} has no public key, skipping`); + return; + } + + if (!user.sweepThreshold || user.sweepThreshold <= 0) { + this.logger.warn(`User ${user.id} has invalid sweep threshold, skipping`); + return; + } + + if (!user.defaultSavingsProductId) { + this.logger.warn(`User ${user.id} has no default savings product, skipping`); + return; + } + + // Calculate excess funds + const excessAmount = await this.calculateExcessFunds(user); + + if (excessAmount <= 0) { + this.logger.debug(`User ${user.id} has no excess funds to sweep`); + return; + } + + this.logger.log( + `User ${user.id} has ${excessAmount} XLM excess funds above threshold ${user.sweepThreshold}`, + ); + + // Execute the sweep (stubbed for now) + await this.executeSweep(user, excessAmount); + } catch (error) { + this.logger.error(`Error processing sweep for user ${user.id}`, error); + } + } + + /** + * Calculate excess funds based on user's wallet balance and threshold + */ + private async calculateExcessFunds(user: User): Promise { + try { + // Get user's current wallet balance from Stellar + const balance = await this.getWalletBalance(user.publicKey); + + this.logger.debug( + `User ${user.id} balance: ${balance} XLM, threshold: ${user.sweepThreshold} XLM`, + ); + + // Calculate excess: balance - threshold + const excess = balance - Number(user.sweepThreshold); + + // Only return positive excess amounts + return excess > 0 ? excess : 0; + } catch (error) { + this.logger.error(`Error calculating excess funds for user ${user.id}`, error); + return 0; + } + } + + /** + * Get wallet balance from Stellar network + * This is a simplified implementation - in production you'd want to: + * - Handle multiple asset types + * - Consider minimum balance requirements + * - Account for transaction fees + */ + private async getWalletBalance(publicKey: string): Promise { + try { + const horizonServer = this.stellarService.getHorizonServer(); + const account = await horizonServer.loadAccount(publicKey); + + // Find XLM (native) balance + const xlmBalance = account.balances.find( + (balance) => balance.asset_type === 'native', + ); + + if (!xlmBalance || xlmBalance.asset_type !== 'native') { + this.logger.warn(`No XLM balance found for ${publicKey}`); + return 0; + } + + return parseFloat(xlmBalance.balance); + } catch (error) { + this.logger.error(`Error fetching balance for ${publicKey}`, error); + return 0; + } + } + + /** + * Execute the actual sweep transaction + * STUB: This is where you would integrate with StellarService to transfer funds + */ + private async executeSweep(user: User, amount: number): Promise { + this.logger.log( + `[STUB] Executing sweep for user ${user.id}: ${amount} XLM to savings product ${user.defaultSavingsProductId}`, + ); + + // TODO: Implement actual transfer logic + // This would involve: + // 1. Creating a Stellar transaction to transfer funds + // 2. Signing the transaction (requires user's secret key or multi-sig setup) + // 3. Submitting to the network + // 4. Recording the sweep in the database + // 5. Creating a UserSubscription record if needed + + // Example pseudo-code: + // const transaction = await this.stellarService.createPaymentTransaction( + // user.publicKey, + // savingsProductWallet, + // amount, + // ); + // await this.stellarService.submitTransaction(transaction); + // await this.recordSweepTransaction(user.id, amount, user.defaultSavingsProductId); + + this.logger.log(`[STUB] Sweep completed for user ${user.id}`); + } + + /** + * Manual trigger for testing purposes + * Can be called via API endpoint if needed + */ + async triggerManualSweep(userId: string): Promise { + this.logger.log(`Manual sweep triggered for user ${userId}`); + + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new Error(`User ${userId} not found`); + } + + if (!user.autoSweepEnabled) { + throw new Error(`Auto-sweep is not enabled for user ${userId}`); + } + + await this.processSweepForUser(user); + } +} diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index d9f5e83f..e08b7750 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateSweepSettingsDto } from './dto/update-sweep-settings.dto'; +import { SweepSettingsDto } from './dto/sweep-settings.dto'; @Injectable() export class UserService {