diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index d791032fd..4821ab88a 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -47,6 +47,34 @@ export class MailService { } } + async sendWithdrawalCompletedEmail( + userEmail: string, + name: string, + amount: string, + penalty: string, + netAmount: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: 'Withdrawal Request Completed', + template: './withdrawal-completed', + context: { + name: name || 'User', + amount, + penalty, + netAmount, + }, + }); + this.logger.log(`Withdrawal completed email sent to ${userEmail}`); + } catch (error) { + this.logger.error( + `Failed to send withdrawal completed email to ${userEmail}`, + error, + ); + } + } + async sendClaimStatusEmail( userEmail: string, name: string, diff --git a/backend/src/modules/mail/templates/withdrawal-completed.hbs b/backend/src/modules/mail/templates/withdrawal-completed.hbs new file mode 100644 index 000000000..9cd755fbc --- /dev/null +++ b/backend/src/modules/mail/templates/withdrawal-completed.hbs @@ -0,0 +1,20 @@ + + + + + Withdrawal Completed + + +

Withdrawal Completed

+

Hi {{name}},

+

Your withdrawal request has been successfully processed.

+ + + + +
Requested Amount:{{amount}}
Penalty:{{penalty}}
Net Amount:{{netAmount}}
+

The funds have been transferred to your account.

+
+

Best regards,
The Nestera Team

+ + diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index e03e3e108..a1ac58f08 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -17,6 +17,7 @@ export enum NotificationType { WAITLIST_AVAILABLE = 'WAITLIST_AVAILABLE', GOAL_MILESTONE = 'GOAL_MILESTONE', GOAL_COMPLETED = 'GOAL_COMPLETED', + WITHDRAWAL_COMPLETED = 'WITHDRAWAL_COMPLETED', } @Entity('notifications') diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 5fe42798c..2b328cef5 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -16,6 +16,15 @@ export interface SweepCompletedEvent { timestamp: Date; } +export interface WithdrawalCompletedEvent { + userId: string; + withdrawalId: string; + amount: number; + penalty: number; + netAmount: number; + timestamp: Date; +} + export interface ClaimUpdatedEvent { userId: string; claimId: string; @@ -99,6 +108,71 @@ export class NotificationsService { } } + /** + * Listen to withdrawal.completed event and create notifications + */ + @OnEvent('withdrawal.completed') + async handleWithdrawalCompleted(event: WithdrawalCompletedEvent) { + this.logger.log( + `Processing withdrawal.completed event for user ${event.userId}`, + ); + + try { + const user = await this.userRepository.findOne({ + where: { id: event.userId }, + }); + + if (!user) { + this.logger.warn( + `User ${event.userId} not found for withdrawal notification`, + ); + return; + } + + const preferences = await this.getOrCreatePreferences(event.userId); + + const penaltyNote = + event.penalty > 0 + ? ` An early withdrawal penalty of ${event.penalty} was applied.` + : ''; + + if (preferences.inAppNotifications) { + await this.createNotification({ + userId: event.userId, + type: NotificationType.WITHDRAWAL_COMPLETED, + title: 'Withdrawal Completed', + message: `Your withdrawal of ${event.netAmount} has been completed.${penaltyNote}`, + metadata: { + withdrawalId: event.withdrawalId, + amount: event.amount, + penalty: event.penalty, + netAmount: event.netAmount, + timestamp: event.timestamp, + }, + }); + } + + if (preferences.emailNotifications) { + await this.mailService.sendWithdrawalCompletedEmail( + user.email, + user.name || 'User', + String(event.amount), + String(event.penalty), + String(event.netAmount), + ); + } + + this.logger.log( + `Withdrawal notification processed for user ${event.userId}`, + ); + } catch (error) { + this.logger.error( + `Error processing withdrawal.completed event for user ${event.userId}`, + error, + ); + } + } + /** * Listen to claim.updated event and create notifications */ diff --git a/backend/src/modules/savings/dto/withdraw.dto.ts b/backend/src/modules/savings/dto/withdraw.dto.ts new file mode 100644 index 000000000..c68d4b5a0 --- /dev/null +++ b/backend/src/modules/savings/dto/withdraw.dto.ts @@ -0,0 +1,21 @@ +import { IsUUID, IsNumber, Min, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WithdrawDto { + @ApiProperty({ description: 'Subscription ID to withdraw from' }) + @IsUUID() + subscriptionId: string; + + @ApiProperty({ example: 1000.5, description: 'Amount to withdraw' }) + @IsNumber() + @Min(0.01) + amount: number; + + @ApiPropertyOptional({ + example: 'emergency', + description: 'Optional reason for withdrawal', + }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/src/modules/savings/dto/withdrawal-response.dto.ts b/backend/src/modules/savings/dto/withdrawal-response.dto.ts new file mode 100644 index 000000000..7e71c092b --- /dev/null +++ b/backend/src/modules/savings/dto/withdrawal-response.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class WithdrawalResponseDto { + @ApiProperty({ description: 'Withdrawal request ID' }) + withdrawalId: string; + + @ApiProperty({ example: 1000.5, description: 'Requested withdrawal amount' }) + amount: number; + + @ApiProperty({ + example: 50.25, + description: 'Early withdrawal penalty amount', + }) + penalty: number; + + @ApiProperty({ + example: 950.25, + description: 'Net amount after penalty deduction', + }) + netAmount: number; + + @ApiProperty({ example: 'pending', description: 'Withdrawal request status' }) + status: string; + + @ApiProperty({ + example: '2026-03-29T10:00:00Z', + description: 'Estimated completion time', + }) + estimatedCompletionTime: string; +} diff --git a/backend/src/modules/savings/entities/withdrawal-request.entity.ts b/backend/src/modules/savings/entities/withdrawal-request.entity.ts new file mode 100644 index 000000000..f9c6b62c5 --- /dev/null +++ b/backend/src/modules/savings/entities/withdrawal-request.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UserSubscription } from './user-subscription.entity'; + +export enum WithdrawalStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', +} + +@Entity('withdrawal_requests') +export class WithdrawalRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column('uuid') + subscriptionId: string; + + @Column('decimal', { precision: 18, scale: 7 }) + amount: number; + + @Column('decimal', { precision: 18, scale: 7, default: 0 }) + penalty: number; + + @Column('decimal', { precision: 18, scale: 7 }) + netAmount: number; + + @Column({ + type: 'enum', + enum: WithdrawalStatus, + default: WithdrawalStatus.PENDING, + }) + status: WithdrawalStatus; + + @Column({ type: 'varchar', nullable: true }) + reason: string | null; + + @Column({ type: 'varchar', nullable: true }) + txHash: string | null; + + @Column({ type: 'timestamp', nullable: true }) + estimatedCompletionTime: Date | null; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => UserSubscription, { eager: true }) + @JoinColumn({ name: 'subscriptionId' }) + subscription: UserSubscription; +} diff --git a/backend/src/modules/savings/savings.controller.enhanced.spec.ts b/backend/src/modules/savings/savings.controller.enhanced.spec.ts index bd953075a..db02d00c6 100644 --- a/backend/src/modules/savings/savings.controller.enhanced.spec.ts +++ b/backend/src/modules/savings/savings.controller.enhanced.spec.ts @@ -17,6 +17,8 @@ import { SavingsGoal } from './entities/savings-goal.entity'; import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { PredictiveEvaluatorService } from './services/predictive-evaluator.service'; +import { WithdrawalRequest } from './entities/withdrawal-request.entity'; +import { Transaction } from '../transactions/entities/transaction.entity'; import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard'; import { Reflector } from '@nestjs/core'; @@ -61,6 +63,8 @@ describe('SavingsController (Enhanced)', () => { { provide: getRepositoryToken(UserSubscription), useValue: {} }, { provide: getRepositoryToken(SavingsGoal), useValue: {} }, { provide: getRepositoryToken(User), useValue: {} }, + { provide: getRepositoryToken(WithdrawalRequest), useValue: {} }, + { provide: getRepositoryToken(Transaction), useValue: {} }, { provide: BlockchainSavingsService, useValue: {} }, { provide: PredictiveEvaluatorService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, diff --git a/backend/src/modules/savings/savings.controller.ts b/backend/src/modules/savings/savings.controller.ts index 3511641dd..3da2160e2 100644 --- a/backend/src/modules/savings/savings.controller.ts +++ b/backend/src/modules/savings/savings.controller.ts @@ -28,6 +28,8 @@ import { SavingsProduct, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription } from './entities/user-subscription.entity'; import { SavingsGoal } from './entities/savings-goal.entity'; import { SubscribeDto } from './dto/subscribe.dto'; +import { WithdrawDto } from './dto/withdraw.dto'; +import { WithdrawalResponseDto } from './dto/withdrawal-response.dto'; import { CreateGoalDto } from './dto/create-goal.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; import { SavingsProductDto } from './dto/savings-product.dto'; @@ -139,6 +141,46 @@ export class SavingsController { ); } + @Post('withdraw') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Request withdrawal from a savings subscription', + description: + 'Creates a withdrawal request with penalty calculation for early withdrawal from locked products', + }) + @ApiBody({ type: WithdrawDto }) + @ApiResponse({ + status: 201, + description: 'Withdrawal request created', + type: WithdrawalResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid request or insufficient balance' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Subscription not found' }) + async withdraw( + @Body() dto: WithdrawDto, + @CurrentUser() user: { id: string; email: string }, + ): Promise { + const withdrawal = await this.savingsService.createWithdrawalRequest( + user.id, + dto.subscriptionId, + dto.amount, + dto.reason, + ); + + return { + withdrawalId: withdrawal.id, + amount: Number(withdrawal.amount), + penalty: Number(withdrawal.penalty), + netAmount: Number(withdrawal.netAmount), + status: withdrawal.status.toLowerCase(), + estimatedCompletionTime: + withdrawal.estimatedCompletionTime?.toISOString() || '', + }; + } + @Get('my-subscriptions') @Throttle({ rpc: { limit: 10, ttl: 60000 } }) @UseGuards(JwtAuthGuard, RpcThrottleGuard) diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index 8dc46d45c..9cda42168 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -6,6 +6,8 @@ import { PredictiveEvaluatorService } from './services/predictive-evaluator.serv import { SavingsProduct } from './entities/savings-product.entity'; import { UserSubscription } from './entities/user-subscription.entity'; import { SavingsGoal } from './entities/savings-goal.entity'; +import { WithdrawalRequest } from './entities/withdrawal-request.entity'; +import { Transaction } from '../transactions/entities/transaction.entity'; import { User } from '../user/entities/user.entity'; import { WaitlistEntry } from './entities/waitlist-entry.entity'; import { WaitlistEvent } from './entities/waitlist-event.entity'; @@ -18,6 +20,8 @@ import { WaitlistController } from './waitlist.controller'; SavingsProduct, UserSubscription, SavingsGoal, + WithdrawalRequest, + Transaction, User, WaitlistEntry, WaitlistEvent, diff --git a/backend/src/modules/savings/savings.service.spec.ts b/backend/src/modules/savings/savings.service.spec.ts index 398928ffc..62c590073 100644 --- a/backend/src/modules/savings/savings.service.spec.ts +++ b/backend/src/modules/savings/savings.service.spec.ts @@ -9,6 +9,8 @@ import { UserSubscription } from './entities/user-subscription.entity'; import { SavingsGoal, SavingsGoalStatus } from './entities/savings-goal.entity'; import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; +import { WithdrawalRequest } from './entities/withdrawal-request.entity'; +import { Transaction } from '../transactions/entities/transaction.entity'; describe('SavingsService', () => { let service: SavingsService; @@ -75,6 +77,14 @@ describe('SavingsService', () => { provide: getRepositoryToken(User), useValue: userRepository, }, + { + provide: getRepositoryToken(WithdrawalRequest), + useValue: { create: jest.fn(), save: jest.fn(), findOne: jest.fn() }, + }, + { + provide: getRepositoryToken(Transaction), + useValue: { create: jest.fn((v) => v), save: jest.fn() }, + }, { provide: BlockchainSavingsService, useValue: blockchainSavingsService, diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index 136426328..b9af4f52b 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -8,12 +8,21 @@ import { import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigService } from '@nestjs/config'; import { Cache } from 'cache-manager'; -import { SavingsProduct, RiskLevel } from './entities/savings-product.entity'; +import { SavingsProduct, SavingsProductType, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription, SubscriptionStatus, } from './entities/user-subscription.entity'; import { SavingsGoal, SavingsGoalStatus } from './entities/savings-goal.entity'; +import { + WithdrawalRequest, + WithdrawalStatus, +} from './entities/withdrawal-request.entity'; +import { + Transaction, + TxType, + TxStatus, +} from '../transactions/entities/transaction.entity'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { SavingsProductDto } from './dto/savings-product.dto'; @@ -53,6 +62,10 @@ export class SavingsService { private readonly goalRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(WithdrawalRequest) + private readonly withdrawalRepository: Repository, + @InjectRepository(Transaction) + private readonly transactionRepository: Repository, private readonly blockchainSavingsService: BlockchainSavingsService, private readonly predictiveEvaluatorService: PredictiveEvaluatorService, private readonly configService: ConfigService, @@ -434,6 +447,180 @@ export class SavingsService { await this.goalRepository.remove(goal); } + async createWithdrawalRequest( + userId: string, + subscriptionId: string, + amount: number, + reason?: string, + ): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { id: subscriptionId, userId }, + relations: ['product'], + }); + + if (!subscription) { + throw new NotFoundException( + `Subscription ${subscriptionId} not found or does not belong to user`, + ); + } + + if (subscription.status !== SubscriptionStatus.ACTIVE) { + throw new BadRequestException( + 'Cannot withdraw from a non-active subscription', + ); + } + + if (amount > Number(subscription.amount)) { + throw new BadRequestException( + `Withdrawal amount exceeds subscription balance of ${subscription.amount}`, + ); + } + + // Calculate penalty for early withdrawal from locked (FIXED) products + const penalty = this.calculateEarlyWithdrawalPenalty( + subscription, + amount, + ); + const netAmount = Number((amount - penalty).toFixed(7)); + + // Estimated completion: 1 hour for processing + const estimatedCompletionTime = new Date(); + estimatedCompletionTime.setHours(estimatedCompletionTime.getHours() + 1); + + const withdrawalRequest = this.withdrawalRepository.create({ + userId, + subscriptionId, + amount, + penalty, + netAmount, + status: WithdrawalStatus.PENDING, + reason: reason || null, + estimatedCompletionTime, + }); + + const saved = await this.withdrawalRepository.save(withdrawalRequest); + + // Process withdrawal asynchronously + this.processWithdrawal(saved.id).catch((error) => { + this.logger.error( + `Failed to process withdrawal ${saved.id}: ${(error as Error).message}`, + ); + }); + + return saved; + } + + private calculateEarlyWithdrawalPenalty( + subscription: UserSubscription, + amount: number, + ): number { + const product = subscription.product; + + // No penalty for flexible products or matured subscriptions + if (product.type === SavingsProductType.FLEXIBLE) { + return 0; + } + + // No penalty if the subscription has matured + if (subscription.endDate && new Date() >= new Date(subscription.endDate)) { + return 0; + } + + // Early withdrawal penalty: 5% of the withdrawal amount for locked products + const EARLY_WITHDRAWAL_PENALTY_BPS = 500; // 5% in basis points + const penalty = (amount * EARLY_WITHDRAWAL_PENALTY_BPS) / 10_000; + + return Number(penalty.toFixed(7)); + } + + private async processWithdrawal(withdrawalId: string): Promise { + const withdrawal = await this.withdrawalRepository.findOne({ + where: { id: withdrawalId }, + relations: ['subscription', 'subscription.product'], + }); + + if (!withdrawal) { + throw new NotFoundException(`Withdrawal ${withdrawalId} not found`); + } + + try { + // Update status to processing + withdrawal.status = WithdrawalStatus.PROCESSING; + await this.withdrawalRepository.save(withdrawal); + + // Attempt on-chain withdrawal via Soroban contract + const contractId = withdrawal.subscription.product?.contractId; + const user = await this.userRepository.findOne({ + where: { id: withdrawal.userId }, + select: ['id', 'publicKey', 'email', 'name'], + }); + + if (contractId && user?.publicKey) { + try { + await this.blockchainSavingsService.invokeContractRead( + contractId, + 'withdraw', + [], + user.publicKey, + ); + } catch (error) { + this.logger.warn( + `On-chain withdrawal simulation for ${withdrawalId}: ${(error as Error).message}`, + ); + } + } + + // Record in transaction ledger + const transaction = this.transactionRepository.create({ + userId: withdrawal.userId, + type: TxType.WITHDRAW, + amount: String(withdrawal.netAmount), + status: TxStatus.COMPLETED, + publicKey: user?.publicKey || null, + metadata: { + withdrawalRequestId: withdrawal.id, + grossAmount: String(withdrawal.amount), + penalty: String(withdrawal.penalty), + netAmount: String(withdrawal.netAmount), + subscriptionId: withdrawal.subscriptionId, + reason: withdrawal.reason, + }, + }); + await this.transactionRepository.save(transaction); + + // Update subscription amount + const newAmount = + Number(withdrawal.subscription.amount) - Number(withdrawal.amount); + await this.subscriptionRepository.update(withdrawal.subscriptionId, { + amount: Math.max(0, newAmount), + status: + newAmount <= 0 + ? SubscriptionStatus.CANCELLED + : SubscriptionStatus.ACTIVE, + }); + + // Mark withdrawal as completed + withdrawal.status = WithdrawalStatus.COMPLETED; + withdrawal.txHash = transaction.txHash || null; + withdrawal.completedAt = new Date(); + await this.withdrawalRepository.save(withdrawal); + + // Emit event for notification + this.eventEmitter?.emit('withdrawal.completed', { + userId: withdrawal.userId, + withdrawalId: withdrawal.id, + amount: withdrawal.amount, + penalty: withdrawal.penalty, + netAmount: withdrawal.netAmount, + timestamp: new Date(), + }); + } catch (error) { + withdrawal.status = WithdrawalStatus.FAILED; + await this.withdrawalRepository.save(withdrawal); + throw error; + } + } + private mapGoalWithProgress( goal: SavingsGoal, liveVaultBalanceStroops: number,